Initial commit: site code, layouts, content (images added separately)
Fixes duplicate placeholder cover image on the 3 newest blog posts, broken aspect-ratio classes on the albums listing page, and a non-responsive fixed sidebar on the chat page.
This commit is contained in:
207
layouts/_default/baseof.html
Normal file
207
layouts/_default/baseof.html
Normal file
@@ -0,0 +1,207 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ .Site.LanguageCode }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src *; img-src * data:; frame-ancestors 'self';">
|
||||
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
|
||||
<meta http-equiv="X-Content-Type-Options" content="nosniff">
|
||||
<meta http-equiv="Permissions-Policy" content="camera=(), microphone=(), geolocation=()">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>
|
||||
<meta name="title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}">
|
||||
<meta name="description" content="{{ if .Description }}{{ .Description }}{{ else if .Summary }}{{ .Summary | truncate 160 }}{{ else }}{{ .Site.Params.description | default "Sterile hydroponics expert helping growers achieve award-winning results. 20 years of experience." }}{{ end }}">
|
||||
<meta name="keywords" content="{{ if .Params.keywords }}{{ delimit .Params.keywords ", " }}{{ else }}sterile hydroponics, hydroponic growing, cannabis cultivation, hydroponic consulting, sterile growing, hydroponic systems{{ end }}">
|
||||
<meta name="author" content="{{ .Site.Params.author | default "Dr. Jones" }}">
|
||||
<link rel="canonical" href="{{ .Permalink }}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
|
||||
<meta property="og:url" content="{{ .Permalink }}">
|
||||
<meta property="og:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
|
||||
<meta property="og:description" content="{{ if .Description }}{{ .Description }}{{ else if .Summary }}{{ .Summary | truncate 160 }}{{ else }}{{ .Site.Params.description | default "Sterile hydroponics expert helping growers achieve award-winning results." }}{{ end }}">
|
||||
{{ if .Params.image }}
|
||||
<meta property="og:image" content="{{ .Params.image | absURL }}">
|
||||
{{ else }}
|
||||
<meta property="og:image" content="{{ "/images/IMG_1767.jpeg" | absURL }}">
|
||||
{{ end }}
|
||||
<meta property="og:site_name" content="{{ .Site.Title }}">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="{{ .Permalink }}">
|
||||
<meta property="twitter:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
|
||||
<meta property="twitter:description" content="{{ if .Description }}{{ .Description }}{{ else if .Summary }}{{ .Summary | truncate 160 }}{{ else }}{{ .Site.Params.description | default "Sterile hydroponics expert helping growers achieve award-winning results." }}{{ end }}">
|
||||
{{ if .Params.image }}
|
||||
<meta property="twitter:image" content="{{ .Params.image | absURL }}">
|
||||
{{ else }}
|
||||
<meta property="twitter:image" content="{{ "/images/IMG_1767.jpeg" | absURL }}">
|
||||
{{ end }}
|
||||
|
||||
<!-- Structured Data (JSON-LD) -->
|
||||
{{ if .IsHome }}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "{{ .Site.Title }}",
|
||||
"url": "{{ .Site.BaseURL }}",
|
||||
"description": "{{ .Site.Params.description | default "Sterile hydroponics expert helping growers achieve award-winning results." }}",
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/drj0nes/"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{{ else if .IsPage }}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "{{ if eq .Section "blog" }}BlogPosting{{ else }}Article{{ end }}",
|
||||
"headline": "{{ .Title }}",
|
||||
"datePublished": "{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}",
|
||||
"dateModified": "{{ if .Lastmod }}{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}{{ else }}{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}{{ end }}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "{{ .Site.Params.author | default "Dr. Jones" }}"
|
||||
},
|
||||
"description": "{{ if .Description }}{{ .Description }}{{ else if .Summary }}{{ .Summary | truncate 160 }}{{ end }}"
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ $styles := resources.Get "css/main.css" | css.PostCSS }}
|
||||
<link rel="stylesheet" href="{{ $styles.RelPermalink }}">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Membership System -->
|
||||
<script src="/js/membership.js"></script>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen font-sans" x-data="{ mobileMenuOpen: false }">
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sticky top-0 z-50 bg-gray-900/90 backdrop-blur-md border-b border-gray-800 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<a href="/" class="flex-shrink-0 flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-white font-bold text-xl">P</div>
|
||||
<span class="font-bold text-xl tracking-tight text-gray-100">Pure<span class="text-primary">Hydro</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Menu -->
|
||||
<div class="hidden md:flex md:items-center md:space-x-8">
|
||||
{{ range .Site.Menus.main }}
|
||||
<a href="{{ .URL }}" class="text-gray-300 hover:text-primary px-3 py-2 rounded-md text-sm font-medium transition-colors {{ if or (eq $.RelPermalink .URL) (eq $.RelPermalink (printf "%s/" .URL)) }}text-primary font-semibold{{ end }}">
|
||||
{{ .Name }}
|
||||
</a>
|
||||
{{ end }}
|
||||
<!-- Membership Login/Logout -->
|
||||
<div class="membership-login" style="display: none;">
|
||||
<a href="/membership/" class="text-gray-300 hover:text-primary px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Login
|
||||
</a>
|
||||
</div>
|
||||
<div class="membership-logout" style="display: none;">
|
||||
<span class="text-gray-300 px-3 py-2 text-sm">
|
||||
<span class="user-name"></span>
|
||||
</span>
|
||||
<button onclick="window.MembershipAPI.logout()" class="text-gray-300 hover:text-primary px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<a href="/services/" class="bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors shadow-sm hover:shadow-md">
|
||||
Work With Me
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<div class="flex items-center md:hidden">
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" type="button" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-300 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div x-show="mobileMenuOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="md:hidden bg-gray-900 border-b border-gray-800 shadow-lg absolute w-full"
|
||||
style="display: none;">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||
{{ range .Site.Menus.main }}
|
||||
<a href="{{ .URL }}" class="text-gray-300 hover:text-primary hover:bg-gray-800 block px-3 py-2 rounded-md text-base font-medium">
|
||||
{{ .Name }}
|
||||
</a>
|
||||
{{ end }}
|
||||
<!-- Mobile Membership Login/Logout -->
|
||||
<div class="membership-login" style="display: none;">
|
||||
<a href="/membership/" class="text-gray-300 hover:text-primary hover:bg-gray-800 block px-3 py-2 rounded-md text-base font-medium">
|
||||
Login
|
||||
</a>
|
||||
</div>
|
||||
<div class="membership-logout" style="display: none;">
|
||||
<span class="text-gray-300 block px-3 py-2 text-base">
|
||||
<span class="user-name"></span>
|
||||
</span>
|
||||
<button onclick="window.MembershipAPI.logout()" class="text-gray-300 hover:text-primary hover:bg-gray-800 block px-3 py-2 rounded-md text-base font-medium w-full text-left">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<a href="/services/" class="text-white bg-primary block px-3 py-2 rounded-md text-base font-medium mt-4 text-center">
|
||||
Work With Me
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-grow">
|
||||
{{ block "main" . }}{{ end }}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 border-t border-gray-800 mt-12">
|
||||
<div class="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center flex-col md:flex-row gap-6">
|
||||
<div class="text-center md:text-left">
|
||||
<span class="font-bold text-xl tracking-tight text-gray-100">Pure<span class="text-primary">Hydro</span></span>
|
||||
<p class="mt-2 text-sm text-gray-400">Cultivating clean, sterile, and abundant gardens.</p>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap justify-center gap-4">
|
||||
<a href="https://www.buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener noreferrer" class="inline-flex items-center px-4 py-2 bg-[#FFDD00] hover:bg-[#FFCC00] text-gray-900 rounded-lg font-semibold transition-colors shadow-sm hover:shadow-md">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.5 3H6c-1.1 0-2 .9-2 2v5.71c0 3.83 2.95 7.18 6.78 7.29 3.96.12 7.22-3.06 7.22-7v-1h.5c1.38 0 2.5-1.12 2.5-2.5S19.88 3 18.5 3zM16 5v3H6V5h10zm2.5 5H18V5h.5c.28 0 .5.22.5.5s-.22.5-.5.5zM4 19h16v2H4v-2z"/>
|
||||
</svg>
|
||||
Buy Me a Coffee
|
||||
</a>
|
||||
<a href="https://www.instagram.com/drj0nes/" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-gray-300">
|
||||
Instagram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 border-t border-gray-800 pt-8 text-center text-sm text-gray-500">
|
||||
© {{ now.Year }} Pure Hydroponics. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
42
layouts/_default/list.html
Normal file
42
layouts/_default/list.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 py-16 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center">
|
||||
<h2 class="text-base text-primary font-semibold tracking-wide uppercase">{{ .Section }}</h2>
|
||||
<p class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-100 sm:text-4xl">{{ .Title }}</p>
|
||||
<p class="mt-4 max-w-2xl text-xl text-gray-300 lg:mx-auto">{{ .Description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid gap-16 lg:grid-cols-3 lg:gap-x-5 lg:gap-y-12">
|
||||
{{ range .Paginator.Pages }}
|
||||
<div class="flex flex-col rounded-lg shadow-lg overflow-hidden">
|
||||
<div class="flex-shrink-0">
|
||||
<img class="h-48 w-full object-cover" src="{{ with .Params.image }}{{ . }}{{ else }}/images/IMG_0599.jpeg{{ end }}" alt="{{ .Title }}">
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-800 p-6 flex flex-col justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-primary">
|
||||
Article
|
||||
</p>
|
||||
<a href="{{ .RelPermalink }}" class="block mt-2">
|
||||
<p class="text-xl font-semibold text-gray-100">{{ .Title }}</p>
|
||||
<p class="mt-3 text-base text-gray-300">{{ .Summary | truncate 150 }}</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center">
|
||||
<div class="text-sm text-gray-400">
|
||||
<time datetime="{{ .Date }}">{{ .Date.Format "Jan 2, 2006" }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ template "_internal/pagination.html" . }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
13
layouts/_default/single.html
Normal file
13
layouts/_default/single.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 py-16 px-4 sm:px-6 lg:px-8">
|
||||
<article class="max-w-3xl mx-auto prose prose-emerald prose-lg prose-invert">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl mb-8">{{ .Title }}</h1>
|
||||
<div class="text-gray-400 mb-10">
|
||||
{{ if .Date }}Published on {{ .Date.Format "January 2, 2006" }}{{ end }}
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{{ end }}
|
||||
360
layouts/admin/single.html
Normal file
360
layouts/admin/single.html
Normal file
@@ -0,0 +1,360 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 min-h-screen py-16 px-4 sm:px-6 lg:px-8" x-data="adminPanel()" x-init="init()">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Login Screen -->
|
||||
<div x-show="!authenticated" class="max-w-md mx-auto">
|
||||
<div class="bg-gray-800 rounded-lg p-8 border border-gray-700">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-6 text-center">Admin Login</h1>
|
||||
<form @submit.prevent="adminLogin()" class="space-y-6">
|
||||
<div>
|
||||
<label for="admin-password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||
<input type="password" id="admin-password" x-model="adminPassword" required
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<div x-show="loginError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200">
|
||||
<p x-text="loginError"></p>
|
||||
</div>
|
||||
<button type="submit"
|
||||
:disabled="loginLoading"
|
||||
class="w-full px-6 py-3 bg-primary hover:bg-emerald-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50">
|
||||
<span x-show="!loginLoading">Login</span>
|
||||
<span x-show="loginLoading">Logging in...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Dashboard -->
|
||||
<div x-show="authenticated" x-transition>
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100">Admin Dashboard</h1>
|
||||
<button @click="logout()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-700 mb-6 flex">
|
||||
<button @click="activeTab = 'members'; loadPendingMembers()"
|
||||
:class="activeTab === 'members' ? 'border-b-2 border-primary text-primary' : 'text-gray-400 hover:text-gray-300'"
|
||||
class="px-6 py-3 font-medium transition-colors">
|
||||
Pending Members
|
||||
</button>
|
||||
<button @click="activeTab = 'testimonials'; loadTestimonials()"
|
||||
:class="activeTab === 'testimonials' ? 'border-b-2 border-primary text-primary' : 'text-gray-400 hover:text-gray-300'"
|
||||
class="px-6 py-3 font-medium transition-colors">
|
||||
Testimonials
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Members Tab -->
|
||||
<div x-show="activeTab === 'members'" x-transition>
|
||||
<div x-show="loadingMembers" class="text-center text-gray-400 py-12">
|
||||
Loading...
|
||||
</div>
|
||||
<div x-show="membersError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200 mb-6">
|
||||
<p x-text="membersError"></p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<template x-for="member in pendingMembers" :key="member.id">
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-100" x-text="member.name"></h3>
|
||||
<p class="text-gray-400" x-text="member.email"></p>
|
||||
<p class="text-sm text-gray-500 mt-2" x-text="'Signed up: ' + new Date(member.created_at).toLocaleString()"></p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="approveMember(member.id)"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
||||
Approve
|
||||
</button>
|
||||
<button @click="denyMember(member.id)"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!loadingMembers && !membersError && pendingMembers.length === 0" class="text-center text-gray-400 py-12">
|
||||
<p>No pending memberships</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Testimonials Tab -->
|
||||
<div x-show="activeTab === 'testimonials'" x-transition>
|
||||
<div x-show="loadingTestimonials" class="text-center text-gray-400 py-12">
|
||||
Loading...
|
||||
</div>
|
||||
<div x-show="testimonialsError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200 mb-6">
|
||||
<p x-text="testimonialsError"></p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<template x-for="testimonial in allTestimonials" :key="testimonial.id">
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-100" x-text="testimonial.author_name"></h3>
|
||||
<p class="text-sm text-gray-400" x-text="new Date(testimonial.created_at).toLocaleString()"></p>
|
||||
<span x-show="testimonial.approved" class="inline-block mt-2 px-2 py-1 bg-green-600/20 text-green-300 text-xs rounded">
|
||||
Approved
|
||||
</span>
|
||||
<span x-show="!testimonial.approved" class="inline-block mt-2 px-2 py-1 bg-yellow-600/20 text-yellow-300 text-xs rounded">
|
||||
Pending
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button x-show="!testimonial.approved"
|
||||
@click="approveTestimonial(testimonial.id)"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
||||
Approve
|
||||
</button>
|
||||
<button @click="deleteTestimonial(testimonial.id)"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-300 leading-relaxed" x-text="testimonial.content"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!loadingTestimonials && !testimonialsError && allTestimonials.length === 0" class="text-center text-gray-400 py-12">
|
||||
<p>No testimonials</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const getAdminApiBase = () => {
|
||||
const host = window.location.hostname;
|
||||
const isLocal = host === 'localhost' || host === '127.0.0.1' || host.startsWith('10.');
|
||||
return isLocal ? 'http://' + host + ':5001' : window.location.origin.replace(/\/$/, '');
|
||||
};
|
||||
const ADMIN_API_BASE = getAdminApiBase() + '/api';
|
||||
|
||||
function adminPanel() {
|
||||
return {
|
||||
authenticated: false,
|
||||
adminToken: null,
|
||||
adminPassword: '',
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
activeTab: 'members',
|
||||
pendingMembers: [],
|
||||
loadingMembers: false,
|
||||
membersError: '',
|
||||
allTestimonials: [],
|
||||
loadingTestimonials: false,
|
||||
testimonialsError: '',
|
||||
|
||||
init() {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (token) {
|
||||
this.adminToken = token;
|
||||
this.authenticated = true;
|
||||
this.loadPendingMembers();
|
||||
}
|
||||
},
|
||||
|
||||
async adminLogin() {
|
||||
this.loginError = '';
|
||||
this.loginLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: this.adminPassword })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
this.loginLoading = false;
|
||||
|
||||
if (response.ok) {
|
||||
this.adminToken = data.token;
|
||||
localStorage.setItem('admin_token', data.token);
|
||||
this.authenticated = true;
|
||||
this.adminPassword = '';
|
||||
this.loadPendingMembers();
|
||||
} else {
|
||||
this.loginError = data.error || 'Login failed';
|
||||
}
|
||||
} catch (error) {
|
||||
this.loginLoading = false;
|
||||
this.loginError = 'Login failed. Please try again.';
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('admin_token');
|
||||
this.adminToken = null;
|
||||
this.authenticated = false;
|
||||
},
|
||||
|
||||
async loadPendingMembers() {
|
||||
this.loadingMembers = true;
|
||||
this.membersError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/pending`, {
|
||||
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
this.loadingMembers = false;
|
||||
|
||||
if (response.ok) {
|
||||
this.pendingMembers = data.members || [];
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
this.logout();
|
||||
this.loginError = 'Session expired. Please login again.';
|
||||
} else {
|
||||
this.membersError = data.error || 'Failed to load members';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.loadingMembers = false;
|
||||
this.membersError = 'Failed to load members';
|
||||
}
|
||||
},
|
||||
|
||||
async approveMember(userId) {
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.adminToken}`
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.loadPendingMembers();
|
||||
alert('Member approved successfully');
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
this.logout();
|
||||
} else {
|
||||
alert(data.error || 'Failed to approve member');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to approve member');
|
||||
}
|
||||
},
|
||||
|
||||
async denyMember(userId) {
|
||||
if (!confirm('Are you sure you want to deny this membership?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/deny`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.adminToken}`
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.loadPendingMembers();
|
||||
alert('Member denied');
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
this.logout();
|
||||
} else {
|
||||
alert(data.error || 'Failed to deny member');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to deny member');
|
||||
}
|
||||
},
|
||||
|
||||
async loadTestimonials() {
|
||||
this.loadingTestimonials = true;
|
||||
this.testimonialsError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/testimonials`, {
|
||||
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
this.loadingTestimonials = false;
|
||||
|
||||
if (response.ok) {
|
||||
this.allTestimonials = data.testimonials || [];
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
this.logout();
|
||||
} else {
|
||||
this.testimonialsError = data.error || 'Failed to load testimonials';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.loadingTestimonials = false;
|
||||
this.testimonialsError = 'Failed to load testimonials';
|
||||
}
|
||||
},
|
||||
|
||||
async approveTestimonial(testimonialId) {
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/testimonials/${testimonialId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.loadTestimonials();
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
this.logout();
|
||||
} else {
|
||||
alert(data.error || 'Failed to approve testimonial');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to approve testimonial');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTestimonial(testimonialId) {
|
||||
if (!confirm('Are you sure you want to delete this testimonial?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ADMIN_API_BASE}/admin/testimonials/${testimonialId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.loadTestimonials();
|
||||
} else {
|
||||
if (response.status === 401) {
|
||||
this.logout();
|
||||
} else {
|
||||
alert(data.error || 'Failed to delete testimonial');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to delete testimonial');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
35
layouts/albums/list.html
Normal file
35
layouts/albums/list.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 py-16 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto text-center">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl">Photo Albums</h1>
|
||||
<p class="mt-4 max-w-2xl text-xl text-gray-300 mx-auto">
|
||||
Visual documentation of successful grows and sterile setups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 max-w-7xl mx-auto grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{{ range .Pages }}
|
||||
<a href="{{ .RelPermalink }}" class="group relative block rounded-xl overflow-hidden shadow-lg hover:shadow-2xl transition-all transform hover:-translate-y-1">
|
||||
<div class="aspect-[4/3] bg-gray-800">
|
||||
<!-- Cover Image (First image from params or fallback) -->
|
||||
{{ $cover := "/images/IMG_0599.jpeg" }}
|
||||
{{ if .Params.cover }}
|
||||
{{ $cover = .Params.cover }}
|
||||
{{ else if .Params.images }}
|
||||
{{ range first 1 .Params.images }}
|
||||
{{ $cover = .url }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<img src="{{ $cover }}" alt="{{ .Title }}" class="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500">
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end p-6">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-white">{{ .Title }}</h3>
|
||||
<p class="text-gray-200 text-sm mt-1">{{ .Params.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
98
layouts/albums/single.html
Normal file
98
layouts/albums/single.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 min-h-screen py-16 px-4 sm:px-6 lg:px-8"
|
||||
x-data="{
|
||||
lightboxOpen: false,
|
||||
activeImage: '',
|
||||
activeCaption: '',
|
||||
images: [
|
||||
{{ range .Params.images }}
|
||||
{ url: '{{ .url }}', caption: '{{ .caption }}' },
|
||||
{{ end }}
|
||||
],
|
||||
currentIndex: 0,
|
||||
openLightbox(index) {
|
||||
this.currentIndex = index;
|
||||
this.activeImage = this.images[index].url;
|
||||
this.activeCaption = this.images[index].caption;
|
||||
this.lightboxOpen = true;
|
||||
},
|
||||
next() {
|
||||
this.currentIndex = (this.currentIndex + 1) % this.images.length;
|
||||
this.activeImage = this.images[this.currentIndex].url;
|
||||
this.activeCaption = this.images[this.currentIndex].caption;
|
||||
},
|
||||
prev() {
|
||||
this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
|
||||
this.activeImage = this.images[this.currentIndex].url;
|
||||
this.activeCaption = this.images[this.currentIndex].caption;
|
||||
}
|
||||
}"
|
||||
@keydown.escape.window="lightboxOpen = false"
|
||||
@keydown.arrow-right.window="next()"
|
||||
@keydown.arrow-left.window="prev()">
|
||||
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<a href="/albums/" class="text-primary hover:text-emerald-700 font-medium mb-4 inline-block">← Back to Albums</a>
|
||||
<h1 class="text-4xl font-extrabold text-gray-100">{{ .Title }}</h1>
|
||||
<p class="mt-4 text-gray-300">{{ .Description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Masonry Grid -->
|
||||
<div class="columns-1 md:columns-2 lg:columns-3 gap-8 space-y-8">
|
||||
<template x-for="(img, index) in images" :key="index">
|
||||
<div class="break-inside-avoid rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all cursor-pointer bg-gray-800"
|
||||
@click="openLightbox(index)">
|
||||
|
||||
<template x-if="img.url.toLowerCase().endsWith('.mov') || img.url.toLowerCase().endsWith('.mp4')">
|
||||
<video class="w-full h-auto object-cover" controls preload="metadata">
|
||||
<source :src="img.url">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</template>
|
||||
|
||||
<template x-if="!(img.url.toLowerCase().endsWith('.mov') || img.url.toLowerCase().endsWith('.mp4'))">
|
||||
<img :src="img.url" :alt="img.caption" class="w-full h-auto">
|
||||
</template>
|
||||
|
||||
<div class="p-4" x-show="img.caption">
|
||||
<p class="text-sm text-gray-600" x-text="img.caption"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox Modal -->
|
||||
<div x-show="lightboxOpen"
|
||||
style="display: none;"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||
x-transition.opacity>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button @click="lightboxOpen = false" class="absolute top-4 right-4 text-white text-4xl font-light hover:text-gray-300 focus:outline-none">×</button>
|
||||
|
||||
<!-- Nav Buttons -->
|
||||
<button @click="prev()" class="absolute left-4 text-white text-4xl font-light hover:text-gray-300 focus:outline-none p-4">‹</button>
|
||||
<button @click="next()" class="absolute right-4 text-white text-4xl font-light hover:text-gray-300 focus:outline-none p-4">›</button>
|
||||
|
||||
<!-- Image/Video Display -->
|
||||
<div class="max-w-5xl max-h-screen p-4 flex flex-col items-center justify-center w-full">
|
||||
|
||||
<template x-if="activeImage.toLowerCase().endsWith('.mov') || activeImage.toLowerCase().endsWith('.mp4')">
|
||||
<video controls autoplay class="max-h-[85vh] max-w-full rounded-lg shadow-2xl">
|
||||
<source :src="activeImage">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</template>
|
||||
|
||||
<template x-if="!(activeImage.toLowerCase().endsWith('.mov') || activeImage.toLowerCase().endsWith('.mp4'))">
|
||||
<img :src="activeImage" class="max-h-[85vh] max-w-full rounded-lg shadow-2xl object-contain">
|
||||
</template>
|
||||
|
||||
<p x-text="activeCaption" class="text-white mt-4 text-lg font-medium tracking-wide"></p>
|
||||
<p class="text-gray-400 text-sm mt-1" x-text="(currentIndex + 1) + ' / ' + images.length"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
498
layouts/chat/single.html
Normal file
498
layouts/chat/single.html
Normal file
@@ -0,0 +1,498 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 min-h-screen py-8 px-4 sm:px-6 lg:px-8" x-data="chatInterface()" x-init="init()">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl mb-4">
|
||||
Chat with Dr. Jones AI
|
||||
</h1>
|
||||
<p class="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Get instant answers about sterile hydroponics, cannabis cultivation, and growing techniques. Drag library files into the chat to ask questions about specific documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout: Sidebar + Chat -->
|
||||
<div class="flex flex-col lg:flex-row gap-6 lg:h-[700px]">
|
||||
<!-- Left Sidebar: Library Files -->
|
||||
<div class="w-full lg:w-80 flex-shrink-0 h-64 lg:h-auto bg-gray-800 rounded-lg shadow-xl border border-gray-700 flex flex-col">
|
||||
<div class="p-4 border-b border-gray-700">
|
||||
<h2 class="text-lg font-bold text-gray-100 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Library Files
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-1">Drag files into chat to reference them</p>
|
||||
</div>
|
||||
|
||||
<!-- Search/Filter -->
|
||||
<div class="p-4 border-b border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
x-model="fileSearch"
|
||||
placeholder="Search files..."
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<template x-if="loadingFiles">
|
||||
<div class="text-center py-8 text-gray-400">
|
||||
<svg class="animate-spin h-6 w-6 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading files...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loadingFiles && filteredFiles.length === 0">
|
||||
<div class="text-center py-8 text-gray-400 text-sm">
|
||||
No files found
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2" x-show="filteredFiles.length > 0">
|
||||
<template x-for="file in filteredFiles" :key="file.file">
|
||||
<div
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, file)"
|
||||
@dragend="dragEnd($event)"
|
||||
class="p-3 bg-gray-700 hover:bg-gray-600 rounded-lg cursor-move transition-colors border border-transparent hover:border-primary/30"
|
||||
:class="{ 'border-primary': isDragging && draggedFile?.file === file.file }"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-100 text-sm leading-tight" x-text="file.title"></div>
|
||||
<div class="text-xs text-gray-400 mt-1" x-text="file.category"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Chat Container -->
|
||||
<div class="flex-1 bg-gray-800 rounded-lg shadow-xl border border-gray-700 flex flex-col">
|
||||
<!-- Messages Area -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto p-6 space-y-4"
|
||||
x-ref="messagesContainer"
|
||||
@dragover.prevent="dragOver($event)"
|
||||
@drop.prevent="dropFile($event)"
|
||||
:class="{ 'bg-primary/5 border-2 border-primary border-dashed': isDragOver }"
|
||||
>
|
||||
<!-- Welcome Message -->
|
||||
<template x-if="messages.length === 0">
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/20 mb-4">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-400 text-lg mb-2">Ask me anything about sterile hydroponics!</p>
|
||||
<p class="text-gray-500 text-sm">Drag library files here to ask questions about them</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Messages -->
|
||||
<template x-for="(message, index) in messages" :key="index">
|
||||
<div class="flex" :class="message.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div class="max-w-[80%] rounded-lg px-4 py-3"
|
||||
:class="message.role === 'user'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-700 text-gray-100'">
|
||||
<div class="flex items-start gap-3">
|
||||
<template x-if="message.role === 'assistant'">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium mb-1" x-show="message.role === 'assistant'">Dr. Jones AI</p>
|
||||
<div class="prose prose-sm prose-invert max-w-none" x-html="formatMessage(message.content)"></div>
|
||||
<!-- Show referenced files -->
|
||||
<template x-if="message.files && message.files.length > 0">
|
||||
<div class="mt-2 pt-2 border-t border-gray-600/50">
|
||||
<div class="text-xs text-gray-400 mb-1">Referenced files:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="file in message.files" :key="file.file">
|
||||
<span class="text-xs px-2 py-0.5 bg-gray-600/50 rounded" x-text="file.title"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="message.role === 'user'">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<template x-if="loading">
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[80%] rounded-lg px-4 py-3 bg-gray-700 text-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex space-x-1">
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0ms"></div>
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 150ms"></div>
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 300ms"></div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400 ml-2">Dr. Jones AI is thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="border-t border-gray-700 p-4">
|
||||
<!-- Selected Files Display -->
|
||||
<div x-show="selectedFiles.length > 0" class="mb-3 pb-3 border-b border-gray-700">
|
||||
<div class="text-xs text-gray-400 mb-2">Files to include in this message:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="file in selectedFiles" :key="file.file">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 bg-primary/20 text-primary rounded-lg text-xs">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span x-text="file.title"></span>
|
||||
<button @click="removeSelectedFile(file)" class="hover:text-primary/70">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="sendMessage()" class="flex gap-3">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="inputMessage"
|
||||
@keydown.enter.prevent="sendMessage()"
|
||||
:disabled="loading || rateLimited"
|
||||
placeholder="Ask about sterile hydroponics, or drag files here to ask questions about them..."
|
||||
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<template x-if="rateLimited">
|
||||
<p class="absolute -top-6 left-0 text-xs text-yellow-400">
|
||||
Rate limit: Please wait a moment before sending another message
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!inputMessage.trim() || loading || rateLimited"
|
||||
class="px-6 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<template x-if="!loading">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
<span>Send</span>
|
||||
</template>
|
||||
<template x-if="loading">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-xs text-gray-500 mt-2 text-center">
|
||||
Powered by Ollama • Responses are AI-generated and should be verified
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div class="mt-8 bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-4">About This Chat</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-100 mb-2">What I Can Help With</h3>
|
||||
<ul class="space-y-1 list-disc list-inside text-gray-400">
|
||||
<li>Sterile hydroponic protocols</li>
|
||||
<li>Nutrient management (EC, pH)</li>
|
||||
<li>System design and setup</li>
|
||||
<li>Problem diagnosis</li>
|
||||
<li>Questions about library documents</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-100 mb-2">How to Use</h3>
|
||||
<ul class="space-y-1 list-disc list-inside text-gray-400">
|
||||
<li>Drag files from the sidebar into chat</li>
|
||||
<li>Ask questions about specific documents</li>
|
||||
<li>Up to 3 files can be referenced per message</li>
|
||||
<li>Files are processed securely on the server</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function chatInterface() {
|
||||
return {
|
||||
messages: [],
|
||||
inputMessage: '',
|
||||
loading: false,
|
||||
rateLimited: false,
|
||||
lastMessageTime: 0,
|
||||
messageCount: 0,
|
||||
rateLimitWindow: 60000,
|
||||
maxMessagesPerWindow: 10,
|
||||
// File selector state
|
||||
libraryFiles: [],
|
||||
loadingFiles: false,
|
||||
fileSearch: '',
|
||||
selectedFiles: [],
|
||||
// Drag and drop state
|
||||
isDragging: false,
|
||||
draggedFile: null,
|
||||
isDragOver: false,
|
||||
|
||||
get filteredFiles() {
|
||||
if (!this.fileSearch.trim()) {
|
||||
return this.libraryFiles;
|
||||
}
|
||||
const search = this.fileSearch.toLowerCase();
|
||||
return this.libraryFiles.filter(file =>
|
||||
file.title.toLowerCase().includes(search) ||
|
||||
file.category.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.apiBase = this.getApiBase();
|
||||
// Load conversation history from sessionStorage
|
||||
const saved = sessionStorage.getItem('chatHistory');
|
||||
if (saved) {
|
||||
try {
|
||||
this.messages = JSON.parse(saved);
|
||||
this.$nextTick(() => this.scrollToBottom());
|
||||
} catch (e) {
|
||||
console.error('Failed to load chat history', e);
|
||||
}
|
||||
}
|
||||
// Load library files
|
||||
this.loadLibraryFiles();
|
||||
},
|
||||
|
||||
getApiBase() {
|
||||
const host = window.location.hostname;
|
||||
const isLocal = host === 'localhost' || host === '127.0.0.1' || host.startsWith('10.');
|
||||
return isLocal ? 'http://localhost:5000' : window.location.origin.replace(/\/$/, '');
|
||||
},
|
||||
|
||||
async loadLibraryFiles() {
|
||||
this.loadingFiles = true;
|
||||
try {
|
||||
const apiUrl = `${this.apiBase}/api/library/files`;
|
||||
|
||||
const response = await fetch(apiUrl);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.libraryFiles = data.files || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load library files:', error);
|
||||
} finally {
|
||||
this.loadingFiles = false;
|
||||
}
|
||||
},
|
||||
|
||||
dragStart(event, file) {
|
||||
this.isDragging = true;
|
||||
this.draggedFile = file;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', JSON.stringify(file));
|
||||
},
|
||||
|
||||
dragEnd(event) {
|
||||
this.isDragging = false;
|
||||
this.draggedFile = null;
|
||||
},
|
||||
|
||||
dragOver(event) {
|
||||
this.isDragOver = true;
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
},
|
||||
|
||||
dropFile(event) {
|
||||
this.isDragOver = false;
|
||||
try {
|
||||
const fileData = event.dataTransfer.getData('text/plain');
|
||||
if (fileData) {
|
||||
const file = JSON.parse(fileData);
|
||||
this.addSelectedFile(file);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error handling drop:', e);
|
||||
}
|
||||
},
|
||||
|
||||
addSelectedFile(file) {
|
||||
// Limit to 3 files max
|
||||
if (this.selectedFiles.length >= 3) {
|
||||
alert('You can reference up to 3 files at a time.');
|
||||
return;
|
||||
}
|
||||
// Don't add duplicates
|
||||
if (!this.selectedFiles.find(f => f.file === file.file)) {
|
||||
this.selectedFiles.push(file);
|
||||
}
|
||||
},
|
||||
|
||||
removeSelectedFile(file) {
|
||||
this.selectedFiles = this.selectedFiles.filter(f => f.file !== file.file);
|
||||
},
|
||||
|
||||
formatMessage(text) {
|
||||
const safe = this.escapeHTML(text || '');
|
||||
return safe
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-gray-900 px-1 py-0.5 rounded text-primary">$1</code>');
|
||||
},
|
||||
|
||||
escapeHTML(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.messagesContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
checkRateLimit() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastMessageTime > this.rateLimitWindow) {
|
||||
this.messageCount = 0;
|
||||
this.lastMessageTime = now;
|
||||
}
|
||||
if (this.messageCount >= this.maxMessagesPerWindow) {
|
||||
this.rateLimited = true;
|
||||
setTimeout(() => {
|
||||
this.rateLimited = false;
|
||||
this.messageCount = 0;
|
||||
}, this.rateLimitWindow - (now - this.lastMessageTime));
|
||||
return false;
|
||||
}
|
||||
this.messageCount++;
|
||||
this.lastMessageTime = now;
|
||||
return true;
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
const message = this.inputMessage.trim();
|
||||
if (!message || this.loading || this.rateLimited) return;
|
||||
|
||||
if (!this.checkRateLimit()) return;
|
||||
|
||||
const safeForDisplay = this.escapeHTML(message);
|
||||
|
||||
// Store selected files for this message
|
||||
const filesForMessage = [...this.selectedFiles];
|
||||
|
||||
// Add user message
|
||||
this.messages.push({
|
||||
role: 'user',
|
||||
content: safeForDisplay,
|
||||
files: filesForMessage.length > 0 ? filesForMessage : undefined
|
||||
});
|
||||
|
||||
this.inputMessage = '';
|
||||
this.selectedFiles = []; // Clear selected files after sending
|
||||
this.loading = true;
|
||||
this.saveHistory();
|
||||
this.scrollToBottom();
|
||||
|
||||
try {
|
||||
const sanitizedMessage = this.sanitizeInput(message);
|
||||
const apiUrl = `${this.apiBase}/api/chat`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: sanitizedMessage,
|
||||
files: filesForMessage.map(f => ({ file: f.file, title: f.title })), // Only send file references
|
||||
history: this.messages.slice(-5)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: data.response || 'Sorry, I encountered an error. Please try again.'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I\'m having trouble connecting right now. Please try again in a moment.'
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.saveHistory();
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
sanitizeInput(input) {
|
||||
return input
|
||||
.replace(/[<>]/g, '')
|
||||
.substring(0, 1000)
|
||||
.trim();
|
||||
},
|
||||
|
||||
saveHistory() {
|
||||
try {
|
||||
sessionStorage.setItem('chatHistory', JSON.stringify(this.messages));
|
||||
} catch (e) {
|
||||
console.error('Failed to save chat history', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
40
layouts/contact/single.html
Normal file
40
layouts/contact/single.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 py-16 px-4 sm:px-6 lg:px-8" x-data="contactPage()" x-init="initContactPage()">
|
||||
<article class="max-w-3xl mx-auto">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl mb-8">{{ .Title }}</h1>
|
||||
<div class="mt-6 prose prose-emerald prose-lg prose-invert max-w-none">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function contactPage() {
|
||||
return {
|
||||
initContactPage() {
|
||||
// Check auth status and show/hide form
|
||||
setTimeout(() => {
|
||||
const authState = window.MembershipAPI.getAuthState();
|
||||
const paywallMessage = document.querySelector('.paywall-message');
|
||||
const paywallContent = document.querySelector('.paywall-content');
|
||||
const membershipOnly = document.querySelectorAll('.membership-only');
|
||||
const guestOnly = document.querySelectorAll('.guest-only');
|
||||
|
||||
if (authState.authenticated) {
|
||||
if (paywallMessage) paywallMessage.style.display = 'none';
|
||||
if (paywallContent) paywallContent.style.display = 'block';
|
||||
membershipOnly.forEach(el => el.style.display = 'block');
|
||||
guestOnly.forEach(el => el.style.display = 'none');
|
||||
} else {
|
||||
if (paywallMessage) paywallMessage.style.display = 'block';
|
||||
if (paywallContent) paywallContent.style.display = 'none';
|
||||
membershipOnly.forEach(el => el.style.display = 'none');
|
||||
guestOnly.forEach(el => el.style.display = 'block');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
308
layouts/earnings/single.html
Normal file
308
layouts/earnings/single.html
Normal file
@@ -0,0 +1,308 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 min-h-screen py-16 px-4 sm:px-6 lg:px-8" x-data="earningsCalculator()">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl mb-4">
|
||||
Discover Your Potential Earnings
|
||||
</h1>
|
||||
<p class="text-xl text-gray-300 max-w-3xl mx-auto">
|
||||
See what you could earn with my sterile hydroponics methodology. Based on proven results: <span class="text-primary font-semibold">1.5lb of cannabis from a 2x2.5x7 space</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Calculator Form -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-xl p-8 mb-8 border border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-6">Grow Room Specifications</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- CFM Airflow -->
|
||||
<div>
|
||||
<label for="cfm" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Room CFM (Cubic Feet per Minute) Airflow
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="cfm"
|
||||
x-model="cfm"
|
||||
@input="calculate()"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="Enter CFM"
|
||||
min="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Number of Plants -->
|
||||
<div>
|
||||
<label for="plants" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Number of Plants
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="plants"
|
||||
x-model="plants"
|
||||
@input="calculate()"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="Enter number"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Number of Lights -->
|
||||
<div>
|
||||
<label for="lights" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Number of Lights
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="lights"
|
||||
x-model="lights"
|
||||
@input="calculate()"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="Enter number"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Total Wattage -->
|
||||
<div>
|
||||
<label for="wattage" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Total Wattage
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="wattage"
|
||||
x-model="wattage"
|
||||
@input="calculate()"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="Enter watts"
|
||||
min="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Width -->
|
||||
<div>
|
||||
<label for="width" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Width (feet)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="width"
|
||||
x-model="width"
|
||||
@input="calculate()"
|
||||
step="0.1"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="Enter width"
|
||||
min="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Height -->
|
||||
<div>
|
||||
<label for="height" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Height (feet)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="height"
|
||||
x-model="height"
|
||||
@input="calculate()"
|
||||
step="0.1"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="Enter height"
|
||||
min="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Length -->
|
||||
<div>
|
||||
<label for="length" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Length (feet)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="length"
|
||||
x-model="length"
|
||||
@input="calculate()"
|
||||
step="0.1"
|
||||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
placeholder="Enter length"
|
||||
min="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Method (Fixed to Hydro) -->
|
||||
<div>
|
||||
<label for="method" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Growing Method
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="method"
|
||||
value="Hydroponics"
|
||||
disabled
|
||||
class="w-full px-4 py-2 bg-gray-600 border border-gray-500 rounded-md text-gray-400 cursor-not-allowed"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-400">Always set to Hydroponics</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div x-show="hasResults"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-4"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
class="space-y-6">
|
||||
|
||||
<!-- Breakdown Table -->
|
||||
<div class="bg-gray-800 rounded-lg shadow-xl p-8 border border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-6">Your Potential Results</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="pb-4 text-sm font-semibold text-gray-300 uppercase">Metric</th>
|
||||
<th class="pb-4 text-sm font-semibold text-gray-300 uppercase text-right">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-200">
|
||||
<tr class="border-b border-gray-700">
|
||||
<td class="py-4 font-medium">Reservoir Size Needed</td>
|
||||
<td class="py-4 text-right">
|
||||
<span class="text-primary font-bold text-lg" x-text="formatNumber(results.waterGallons)"></span>
|
||||
<span class="text-gray-400 ml-2">gallons</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700">
|
||||
<td class="py-4 font-medium">Total Yield (Grams)</td>
|
||||
<td class="py-4 text-right">
|
||||
<span class="text-primary font-bold text-lg" x-text="formatNumber(results.totalGrams)"></span>
|
||||
<span class="text-gray-400 ml-2">grams</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700">
|
||||
<td class="py-4 font-medium">Price per Gram</td>
|
||||
<td class="py-4 text-right">
|
||||
<span class="text-primary font-bold text-lg">$10.00</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-700">
|
||||
<td class="py-4 font-medium">Grow Cycle Duration</td>
|
||||
<td class="py-4 text-right">
|
||||
<span class="text-primary font-bold text-lg">2.5</span>
|
||||
<span class="text-gray-400 ml-2">months</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grand Totals - Exciting Display -->
|
||||
<div class="bg-gradient-to-br from-primary/20 to-emerald-600/20 rounded-lg shadow-xl p-8 border-2 border-primary">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-extrabold text-gray-100 mb-4">
|
||||
🎉 Potential Earnings 🎉
|
||||
</h2>
|
||||
<div class="mb-6">
|
||||
<p class="text-lg text-gray-300 mb-2">Total Revenue</p>
|
||||
<p class="text-6xl font-bold text-primary mb-2" x-text="'$' + formatNumber(results.totalRevenue)"></p>
|
||||
<p class="text-xl text-gray-400" x-text="'From ' + formatNumber(results.totalGrams) + ' grams in just 2.5 months!'"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-6 bg-gray-800/50 rounded-lg border border-primary/30">
|
||||
<p class="text-2xl font-bold text-gray-100 mb-2">
|
||||
<span class="text-primary">WOAH!</span> You could make that much!
|
||||
</p>
|
||||
<p class="text-gray-300 mt-4">
|
||||
This is what's possible with my sterile hydroponics methodology.
|
||||
<span class="text-primary font-semibold">Ready to make it happen?</span>
|
||||
</p>
|
||||
<a href="/services/" class="inline-block mt-6 px-8 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-emerald-700 transition-colors shadow-lg hover:shadow-xl">
|
||||
Work With Me
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!hasResults"
|
||||
class="text-center py-12 bg-gray-800 rounded-lg border border-gray-700">
|
||||
<p class="text-gray-400 text-lg">Enter your grow room specifications above to see your potential earnings!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function earningsCalculator() {
|
||||
return {
|
||||
// Input values
|
||||
cfm: '',
|
||||
plants: '',
|
||||
lights: '',
|
||||
wattage: '',
|
||||
width: '',
|
||||
height: '',
|
||||
length: '',
|
||||
|
||||
// Results
|
||||
results: {
|
||||
waterGallons: 0,
|
||||
totalGrams: 0,
|
||||
totalRevenue: 0
|
||||
},
|
||||
|
||||
// Calculation constants
|
||||
// Base ratio: 2x2.5x7 space = 1.5lb
|
||||
// 2 * 2.5 * 7 = 35 cubic feet = 1.5lb = 680.39 grams
|
||||
BASE_CUBIC_FEET: 35, // 2 * 2.5 * 7
|
||||
BASE_GRAMS: 680.39, // 1.5lb in grams
|
||||
GALLONS_PER_PLANT: 0.7,
|
||||
PRICE_PER_GRAM: 10,
|
||||
|
||||
get hasResults() {
|
||||
return this.width > 0 && this.height > 0 && this.length > 0 && this.plants > 0;
|
||||
},
|
||||
|
||||
calculate() {
|
||||
if (!this.hasResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate cubic feet of grow space
|
||||
const cubicFeet = parseFloat(this.width) * parseFloat(this.height) * parseFloat(this.length);
|
||||
|
||||
// Calculate yield based on ratio
|
||||
// If user's space is 35 cubic feet, they get 680.39 grams
|
||||
// Scale proportionally
|
||||
const scaleFactor = cubicFeet / this.BASE_CUBIC_FEET;
|
||||
const totalGrams = this.BASE_GRAMS * scaleFactor;
|
||||
|
||||
// Calculate water needed: 0.7 gallons per plant
|
||||
const waterGallons = parseFloat(this.plants) * this.GALLONS_PER_PLANT;
|
||||
|
||||
// Calculate total revenue
|
||||
const totalRevenue = totalGrams * this.PRICE_PER_GRAM;
|
||||
|
||||
// Update results
|
||||
this.results = {
|
||||
waterGallons: Math.round(waterGallons * 10) / 10, // Round to 1 decimal
|
||||
totalGrams: Math.round(totalGrams * 10) / 10, // Round to 1 decimal
|
||||
totalRevenue: Math.round(totalRevenue * 100) / 100 // Round to 2 decimals
|
||||
};
|
||||
},
|
||||
|
||||
formatNumber(num) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}).format(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
121
layouts/index.html
Normal file
121
layouts/index.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{{ define "main" }}
|
||||
<!-- Hero Section -->
|
||||
<div class="relative bg-background overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="relative z-10 pb-8 bg-background sm:pb-16 md:pb-20 lg:max-w-2xl lg:w-full lg:pb-28 xl:pb-32">
|
||||
<main class="mt-10 mx-auto max-w-7xl px-4 sm:mt-12 sm:px-6 md:mt-16 lg:mt-20 lg:px-8 xl:mt-28">
|
||||
<div class="sm:text-center lg:text-left">
|
||||
<h1 class="text-4xl tracking-tight font-extrabold text-gray-100 sm:text-5xl md:text-6xl">
|
||||
<span class="block xl:inline">Unlock the Power of</span>
|
||||
<span class="block text-primary">Sterile Hydroponics</span>
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-gray-300 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0">
|
||||
Disease-free roots, accelerated growth, and maximum yields. Learn how a clinical approach to gardening transforms your harvest.
|
||||
</p>
|
||||
<div class="mt-5 sm:mt-8 sm:flex sm:justify-center lg:justify-start">
|
||||
<div class="rounded-md shadow">
|
||||
<a href="/services/" class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-primary hover:bg-emerald-700 md:py-4 md:text-lg md:px-10">
|
||||
Start Mentorship
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-3 sm:mt-0 sm:ml-3">
|
||||
<a href="/library/" class="w-full flex items-center justify-center px-8 py-3 border border-primary text-base font-medium rounded-md text-primary bg-gray-800 hover:bg-gray-700 md:py-4 md:text-lg md:px-10">
|
||||
Free Guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:absolute lg:inset-y-0 lg:right-0 lg:w-1/2">
|
||||
<img class="h-56 w-full object-cover sm:h-72 md:h-96 lg:w-full lg:h-full" src="/images/IMG_1767.jpeg" alt="Sterile Hydroponics Setup">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benefits Section -->
|
||||
<div class="py-12 bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="lg:text-center">
|
||||
<h2 class="text-base text-primary font-semibold tracking-wide uppercase">Why Sterile?</h2>
|
||||
<p class="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-100 sm:text-4xl">
|
||||
A Better Way to Grow
|
||||
</p>
|
||||
<p class="mt-4 max-w-2xl text-xl text-gray-300 lg:mx-auto">
|
||||
Eliminate pathogens before they start. Our sterile methodology ensures your plants spend energy on growth, not fighting disease.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<dl class="space-y-10 md:space-y-0 md:grid md:grid-cols-3 md:gap-x-8 md:gap-y-10">
|
||||
<div class="relative">
|
||||
<dt>
|
||||
<div class="absolute flex items-center justify-center h-12 w-12 rounded-md bg-primary text-white">
|
||||
<!-- Icon -->
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-lg leading-6 font-medium text-gray-100">Zero Pathogens</p>
|
||||
</dt>
|
||||
<dd class="mt-2 ml-16 text-base text-gray-300">
|
||||
By maintaining a sterile reservoir, you prevent Pythium (root rot) and other fungal issues completely.
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<dt>
|
||||
<div class="absolute flex items-center justify-center h-12 w-12 rounded-md bg-primary text-white">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-lg leading-6 font-medium text-gray-100">Maximum Uptake</p>
|
||||
</dt>
|
||||
<dd class="mt-2 ml-16 text-base text-gray-300">
|
||||
Clean water allows for optimal nutrient absorption without biofilm interference.
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<dt>
|
||||
<div class="absolute flex items-center justify-center h-12 w-12 rounded-md bg-primary text-white">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-lg leading-6 font-medium text-gray-100">Clinical Precision</p>
|
||||
</dt>
|
||||
<dd class="mt-2 ml-16 text-base text-gray-300">
|
||||
Data-driven gardening using precise measurements and sterile tools for predictable success.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Posts (Placeholder) -->
|
||||
<div class="py-12 bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-extrabold text-gray-100">Recent from the Journal</h2>
|
||||
<div class="mt-6 grid gap-16 lg:grid-cols-3 lg:gap-x-5 lg:gap-y-12">
|
||||
{{ range first 3 (where .Site.RegularPages "Section" "blog") }}
|
||||
<div class="flex flex-col rounded-lg shadow-lg overflow-hidden">
|
||||
<div class="flex-shrink-0">
|
||||
<img class="h-48 w-full object-cover" src="{{ with .Params.image }}{{ . }}{{ else }}/images/IMG_0599.jpeg{{ end }}" alt="{{ .Title }}">
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-900 p-6 flex flex-col justify-between">
|
||||
<div class="flex-1">
|
||||
<a href="{{ .RelPermalink }}" class="block mt-2">
|
||||
<p class="text-xl font-semibold text-gray-100">{{ .Title }}</p>
|
||||
<p class="mt-3 text-base text-gray-300">{{ .Summary | truncate 100 }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
86
layouts/library/list.html
Normal file
86
layouts/library/list.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 min-h-screen py-16 px-4 sm:px-6 lg:px-8" x-data="{ category: 'All' }" x-init="window.MembershipAPI.checkDownloadLimit()">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl">Resource Library</h1>
|
||||
<p class="mt-4 max-w-2xl text-xl text-gray-300 mx-auto">
|
||||
Download guides, checklists, and protocols to upgrade your hydroponic game.
|
||||
<br>
|
||||
<span class="text-sm text-gray-400 italic">For educational purposes only.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Paywall Message -->
|
||||
<div class="paywall-message mt-8 bg-gray-800 rounded-lg p-8 border border-gray-700 text-center" style="display: none;">
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-4">Download Limit Reached</h2>
|
||||
<p class="text-gray-300 mb-6">
|
||||
You've used your free download. Become a member for unlimited access to all resources.
|
||||
</p>
|
||||
<a href="/membership/" class="inline-flex items-center px-6 py-3 bg-primary hover:bg-emerald-700 text-white font-semibold rounded-lg transition-colors">
|
||||
Become a Member
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Download Limit Info -->
|
||||
<div class="guest-only mt-6 bg-blue-900/30 border border-blue-700 rounded-lg p-4 text-center" style="display: block;">
|
||||
<p class="text-blue-200">
|
||||
<span class="font-semibold">Free users:</span> 1 download allowed.
|
||||
<a href="/membership/" class="underline hover:text-blue-100">Sign up for membership</a> for unlimited downloads.
|
||||
</p>
|
||||
</div>
|
||||
<div class="membership-only mt-6 bg-green-900/30 border border-green-700 rounded-lg p-4 text-center" style="display: none;">
|
||||
<p class="text-green-200">
|
||||
<span class="font-semibold">Member:</span> Unlimited downloads available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-10 flex justify-center flex-wrap gap-2">
|
||||
{{ $categories := slice "All" }}
|
||||
{{ range .Site.Data.library }}
|
||||
{{ $categories = $categories | append .category }}
|
||||
{{ end }}
|
||||
{{ range ($categories | uniq) }}
|
||||
<button
|
||||
@click="category = '{{ . }}'"
|
||||
:class="{ 'bg-primary text-white': category === '{{ . }}', 'bg-gray-800 text-gray-300 hover:bg-gray-700': category !== '{{ . }}' }"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium shadow-sm transition-colors border border-gray-700">
|
||||
{{ . }}
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="paywall-content mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{{ range .Site.Data.library }}
|
||||
<div x-show="category === 'All' || category === '{{ .category }}'"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
class="bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow overflow-hidden flex flex-col border border-gray-700">
|
||||
<div class="p-6 flex-1">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/20 text-primary">
|
||||
{{ .category }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">{{ .date }}</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<svg class="h-8 w-8 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-100">{{ .title }}</h3>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-300">{{ .description }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-900 px-6 py-4 border-t border-gray-700">
|
||||
<a href="{{ .file }}" download data-file-path="{{ .file }}" class="download-link flex items-center justify-center w-full px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary bg-white hover:bg-gray-50 border-primary transition-colors">
|
||||
Download {{ .filename }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
221
layouts/membership/single.html
Normal file
221
layouts/membership/single.html
Normal file
@@ -0,0 +1,221 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 min-h-screen py-16 px-4 sm:px-6 lg:px-8" x-data="membershipPage()">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl mb-4">Become a Member</h1>
|
||||
<p class="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Unlock exclusive access to premium content, unlimited downloads, and direct communication with Dr. Jones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Benefits Section -->
|
||||
<div class="bg-gray-800 rounded-lg p-8 mb-8 border border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-6">Member Benefits</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-primary rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-100 mb-2">Unlimited Downloads</h3>
|
||||
<p class="text-gray-300">Access and download all resources from the library without limits.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-primary rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-100 mb-2">Direct Email Access</h3>
|
||||
<p class="text-gray-300">Get my direct email address and submit contact forms for personalized help.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-primary rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-100 mb-2">Member Testimonials</h3>
|
||||
<p class="text-gray-300">Read and share success stories from other growers using the sterile method.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-primary rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-100 mb-2">Priority Support</h3>
|
||||
<p class="text-gray-300">Members get priority responses and access to exclusive content updates.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Me a Coffee Section -->
|
||||
<div class="bg-gradient-to-r from-[#FFDD00] to-[#FFCC00] rounded-lg p-8 mb-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">Support the Method</h2>
|
||||
<p class="text-lg text-gray-800 mb-6">
|
||||
Membership requires a $5 minimum donation via Buy Me a Coffee. This helps support continued development of sterile hydroponic protocols and resources.
|
||||
</p>
|
||||
<a href="https://www.buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-8 py-4 bg-gray-900 hover:bg-gray-800 text-white rounded-lg font-bold text-lg transition-colors shadow-lg">
|
||||
<svg class="w-6 h-6 mr-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.5 3H6c-1.1 0-2 .9-2 2v5.71c0 3.83 2.95 7.18 6.78 7.29 3.96.12 7.22-3.06 7.22-7v-1h.5c1.38 0 2.5-1.12 2.5-2.5S19.88 3 18.5 3zM16 5v3H6V5h10zm2.5 5H18V5h.5c.28 0 .5.22.5.5s-.22.5-.5.5zM4 19h16v2H4v-2z"/>
|
||||
</svg>
|
||||
Donate $5 Minimum
|
||||
</a>
|
||||
<p class="text-sm text-gray-700 mt-4 italic">After completing your donation, return here to sign up for membership.</p>
|
||||
</div>
|
||||
|
||||
<!-- Login/Register Tabs -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div class="border-b border-gray-700 flex">
|
||||
<button @click="activeTab = 'login'"
|
||||
:class="activeTab === 'login' ? 'bg-primary text-white' : 'text-gray-300 hover:text-gray-100'"
|
||||
class="flex-1 px-6 py-4 font-medium transition-colors">
|
||||
Login
|
||||
</button>
|
||||
<button @click="activeTab = 'register'"
|
||||
:class="activeTab === 'register' ? 'bg-primary text-white' : 'text-gray-300 hover:text-gray-100'"
|
||||
class="flex-1 px-6 py-4 font-medium transition-colors">
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<!-- Login Form -->
|
||||
<div x-show="activeTab === 'login'" x-transition>
|
||||
<form @submit.prevent="handleLogin()" class="space-y-6">
|
||||
<div>
|
||||
<label for="login-email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
|
||||
<input type="email" id="login-email" x-model="loginEmail" required
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||
<input type="password" id="login-password" x-model="loginPassword" required
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<div x-show="loginError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200">
|
||||
<p x-text="loginError"></p>
|
||||
</div>
|
||||
<button type="submit"
|
||||
:disabled="loginLoading"
|
||||
class="w-full px-6 py-3 bg-primary hover:bg-emerald-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50">
|
||||
<span x-show="!loginLoading">Login</span>
|
||||
<span x-show="loginLoading">Logging in...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<div x-show="activeTab === 'register'" x-transition>
|
||||
<form @submit.prevent="handleRegister()" class="space-y-6">
|
||||
<div>
|
||||
<label for="register-name" class="block text-sm font-medium text-gray-300 mb-2">Full Name</label>
|
||||
<input type="text" id="register-name" x-model="registerName" required
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="register-email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
|
||||
<input type="email" id="register-email" x-model="registerEmail" required
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="register-password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||
<input type="password" id="register-password" x-model="registerPassword" required minlength="8"
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<p class="mt-1 text-sm text-gray-400">Must be at least 8 characters</p>
|
||||
</div>
|
||||
<div x-show="registerError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200">
|
||||
<p x-text="registerError"></p>
|
||||
</div>
|
||||
<div x-show="registerSuccess" class="bg-green-900/50 border border-green-700 rounded-lg p-4 text-green-200">
|
||||
<p x-text="registerSuccess"></p>
|
||||
</div>
|
||||
<button type="submit"
|
||||
:disabled="registerLoading"
|
||||
class="w-full px-6 py-3 bg-primary hover:bg-emerald-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50">
|
||||
<span x-show="!registerLoading">Sign Up</span>
|
||||
<span x-show="registerLoading">Creating account...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Message -->
|
||||
<div x-show="statusMessage" class="mt-6 p-4 rounded-lg"
|
||||
:class="statusMessageType === 'success' ? 'bg-green-900/50 border border-green-700 text-green-200' : 'bg-blue-900/50 border border-blue-700 text-blue-200'">
|
||||
<p x-text="statusMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function membershipPage() {
|
||||
return {
|
||||
activeTab: 'login',
|
||||
loginEmail: '',
|
||||
loginPassword: '',
|
||||
loginError: '',
|
||||
loginLoading: false,
|
||||
registerName: '',
|
||||
registerEmail: '',
|
||||
registerPassword: '',
|
||||
registerError: '',
|
||||
registerSuccess: '',
|
||||
registerLoading: false,
|
||||
statusMessage: '',
|
||||
statusMessageType: 'info',
|
||||
|
||||
async handleLogin() {
|
||||
this.loginError = '';
|
||||
this.loginLoading = true;
|
||||
|
||||
const result = await window.MembershipAPI.login(this.loginEmail, this.loginPassword);
|
||||
|
||||
this.loginLoading = false;
|
||||
|
||||
if (result.success) {
|
||||
this.statusMessage = 'Login successful! Redirecting...';
|
||||
this.statusMessageType = 'success';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} else {
|
||||
this.loginError = result.error || 'Login failed';
|
||||
}
|
||||
},
|
||||
|
||||
async handleRegister() {
|
||||
this.registerError = '';
|
||||
this.registerSuccess = '';
|
||||
this.registerLoading = true;
|
||||
|
||||
const result = await window.MembershipAPI.register(this.registerEmail, this.registerPassword, this.registerName);
|
||||
|
||||
this.registerLoading = false;
|
||||
|
||||
if (result.success) {
|
||||
this.registerSuccess = result.message || 'Registration successful! Your membership is pending approval. You will receive an email when approved.';
|
||||
this.registerEmail = '';
|
||||
this.registerPassword = '';
|
||||
this.registerName = '';
|
||||
} else {
|
||||
this.registerError = result.error || 'Registration failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
34
layouts/services/list.html
Normal file
34
layouts/services/list.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 py-16 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto text-center">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl">Work With Me</h1>
|
||||
<p class="mt-4 max-w-2xl text-xl text-gray-300 mx-auto">
|
||||
Professional guidance to fast-track your success.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-16 grid gap-8 lg:grid-cols-3">
|
||||
{{ range .Pages }}
|
||||
<div class="relative bg-gray-800 border border-gray-700 rounded-2xl shadow-sm flex flex-col p-8 hover:shadow-lg transition-shadow">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-semibold text-gray-100">{{ .Title }}</h3>
|
||||
<p class="mt-4 flex items-baseline text-gray-100">
|
||||
<span class="text-4xl font-extrabold tracking-tight">{{ .Params.price }}</span>
|
||||
{{ if .Params.price_period }}<span class="ml-1 text-xl font-semibold text-gray-400">{{ .Params.price_period }}</span>{{ end }}
|
||||
</p>
|
||||
<div class="mt-6 text-gray-300 space-y-4 prose prose-emerald">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<a href="{{ .Params.foxy_link | default "#" }}"
|
||||
class="w-full bg-primary border border-transparent rounded-md py-3 px-8 flex items-center justify-center text-base font-medium text-white hover:bg-emerald-700 md:text-lg transition-colors">
|
||||
{{ .Params.cta | default "Book Session" }}
|
||||
</a>
|
||||
<p class="mt-4 text-xs text-gray-400 text-center">Secured by Stripe</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
159
layouts/testimonials/single.html
Normal file
159
layouts/testimonials/single.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{{ define "main" }}
|
||||
<div class="bg-gray-900 min-h-screen py-16 px-4 sm:px-6 lg:px-8" x-data="testimonialsPage()" x-init="initTestimonials()">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl mb-4">Member Testimonials</h1>
|
||||
<p class="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Real stories from growers who have transformed their gardens using the sterile hydroponic method.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Paywall Message -->
|
||||
<div class="paywall-message bg-gray-800 rounded-lg p-8 border border-gray-700 text-center mb-8" style="display: none;">
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-4">Members Only Content</h2>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Testimonials are available exclusively to members. Sign up to read success stories and share your own experience.
|
||||
</p>
|
||||
<a href="/membership/" class="inline-flex items-center px-6 py-3 bg-primary hover:bg-emerald-700 text-white font-semibold rounded-lg transition-colors">
|
||||
Become a Member
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Testimonials Content -->
|
||||
<div class="paywall-content">
|
||||
<!-- Submit Testimonial Form (Members Only) -->
|
||||
<div class="membership-only bg-gray-800 rounded-lg p-8 mb-8 border border-gray-700" style="display: none;">
|
||||
<h2 class="text-2xl font-bold text-gray-100 mb-6">Share Your Story</h2>
|
||||
<form @submit.prevent="submitTestimonial()" class="space-y-6">
|
||||
<div>
|
||||
<label for="author-name" class="block text-sm font-medium text-gray-300 mb-2">Your Name</label>
|
||||
<input type="text" id="author-name" x-model="authorName" required
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="testimonial-content" class="block text-sm font-medium text-gray-300 mb-2">Your Testimonial</label>
|
||||
<textarea id="testimonial-content" x-model="testimonialContent" required rows="6"
|
||||
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent"></textarea>
|
||||
</div>
|
||||
<div x-show="submitError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200">
|
||||
<p x-text="submitError"></p>
|
||||
</div>
|
||||
<div x-show="submitSuccess" class="bg-green-900/50 border border-green-700 rounded-lg p-4 text-green-200">
|
||||
<p x-text="submitSuccess"></p>
|
||||
</div>
|
||||
<button type="submit"
|
||||
:disabled="submitLoading"
|
||||
class="px-6 py-3 bg-primary hover:bg-emerald-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50">
|
||||
<span x-show="!submitLoading">Submit Testimonial</span>
|
||||
<span x-show="submitLoading">Submitting...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Testimonials List -->
|
||||
<div class="space-y-6">
|
||||
<div x-show="loading" class="text-center text-gray-400 py-12">
|
||||
Loading testimonials...
|
||||
</div>
|
||||
<div x-show="error" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200">
|
||||
<p x-text="error"></p>
|
||||
</div>
|
||||
<template x-for="testimonial in testimonials" :key="testimonial.id">
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-100" x-text="testimonial.author_name"></h3>
|
||||
<p class="text-sm text-gray-400" x-text="new Date(testimonial.created_at).toLocaleDateString()"></p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-300 leading-relaxed" x-text="testimonial.content"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!loading && !error && testimonials.length === 0" class="text-center text-gray-400 py-12">
|
||||
<p>No testimonials yet. Be the first to share your success story!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function testimonialsPage() {
|
||||
return {
|
||||
testimonials: [],
|
||||
loading: true,
|
||||
error: '',
|
||||
authorName: '',
|
||||
testimonialContent: '',
|
||||
submitError: '',
|
||||
submitSuccess: '',
|
||||
submitLoading: false,
|
||||
|
||||
initTestimonials() {
|
||||
// Check auth and show/hide paywall
|
||||
const authState = window.MembershipAPI.getAuthState();
|
||||
const paywallMessage = document.querySelector('.paywall-message');
|
||||
const paywallContent = document.querySelector('.paywall-content');
|
||||
const membershipOnly = document.querySelectorAll('.membership-only');
|
||||
|
||||
if (!authState.authenticated) {
|
||||
if (paywallMessage) paywallMessage.style.display = 'block';
|
||||
if (paywallContent) paywallContent.style.display = 'none';
|
||||
membershipOnly.forEach(el => el.style.display = 'none');
|
||||
this.loading = false;
|
||||
} else {
|
||||
if (paywallMessage) paywallMessage.style.display = 'none';
|
||||
if (paywallContent) paywallContent.style.display = 'block';
|
||||
membershipOnly.forEach(el => el.style.display = 'block');
|
||||
this.loadTestimonials();
|
||||
}
|
||||
},
|
||||
|
||||
async loadTestimonials() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
const authState = window.MembershipAPI.getAuthState();
|
||||
if (!authState.authenticated) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.MembershipAPI.loadTestimonials();
|
||||
this.loading = false;
|
||||
|
||||
if (result.success) {
|
||||
this.testimonials = result.testimonials || [];
|
||||
} else {
|
||||
this.error = result.error || 'Failed to load testimonials';
|
||||
}
|
||||
},
|
||||
|
||||
async submitTestimonial() {
|
||||
if (!this.authorName || !this.testimonialContent) {
|
||||
this.submitError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitError = '';
|
||||
this.submitSuccess = '';
|
||||
this.submitLoading = true;
|
||||
|
||||
const result = await window.MembershipAPI.submitTestimonial(this.testimonialContent, this.authorName);
|
||||
this.submitLoading = false;
|
||||
|
||||
if (result.success) {
|
||||
this.submitSuccess = result.message || 'Testimonial submitted successfully!';
|
||||
this.authorName = '';
|
||||
this.testimonialContent = '';
|
||||
// Reload testimonials after a delay
|
||||
setTimeout(() => this.loadTestimonials(), 2000);
|
||||
} else {
|
||||
this.submitError = result.error || 'Failed to submit testimonial';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user