fix(picture): migrate to Astro 5.x content collections and fix TypeScript errors

- Rename dynamic routes from [slug].astro to [...slug].astro for multi-segment paths
- Replace deprecated entry.slug with entry.id across all components and utils
- Fix TypeScript implicit any types in TemplateFilters and prompt-templates
- Add proper type narrowing for feature.note in pricing page
- Remove unused marked import from FAQCard
- Delete invalid placeholder content files
- Add shared-landing-ui dependency and integrate StepsSection/PricingSection
- Update tailwind config with shared-landing-ui content paths
- Add global.css with Indigo/Violet dark theme variables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 18:13:57 +01:00
parent 6537863696
commit 36b85fc8a0
29 changed files with 248 additions and 60 deletions

View file

@ -16,6 +16,7 @@
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@manacore/shared-landing-ui": "workspace:*",
"@picture/design-tokens": "workspace:*",
"astro": "^5.16.0",
"astro-i18next": "1.0.0-beta.21",

View file

@ -103,7 +103,7 @@ const primaryMetric = data.metrics[0] || null;
{
!featured && data.metrics.length > 0 && (
<div class="mb-4 grid grid-cols-3 gap-2">
{data.metrics.slice(0, 3).map((metric) => (
{data.metrics.slice(0, 3).map((metric: { value: string; label: string }) => (
<div class="rounded-lg bg-gray-50 p-2 text-center dark:bg-gray-700">
<div class="text-sm font-bold text-blue-600">{metric.value}</div>
<div class="text-xs text-gray-600 dark:text-gray-400">{metric.label}</div>
@ -117,7 +117,7 @@ const primaryMetric = data.metrics[0] || null;
{
data.tags.length > 0 && (
<div class="mb-4 flex flex-wrap gap-2">
{data.tags.slice(0, 3).map((tag) => (
{data.tags.slice(0, 3).map((tag: string) => (
<span class="rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{tag}
</span>

View file

@ -45,7 +45,7 @@ const isRecent = isRecentRelease(data.releaseDate);
)}
</div>
<a href={`/changelog/${data.slug}`} class="entry-title-link">
<a href={`/changelog/${entry.id}`} class="entry-title-link">
<h3 class="entry-title">{data.title}</h3>
</a>
@ -74,7 +74,7 @@ const isRecent = isRecentRelease(data.releaseDate);
</div>
</div>
<a href={`/changelog/${data.slug}`} class="read-more-btn">
<a href={`/changelog/${entry.id}`} class="read-more-btn">
Read More
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />

View file

@ -11,7 +11,7 @@ const { data } = comparison;
---
<a
href={`/comparisons/${data.slug}`}
href={`/comparisons/${comparison.id}`}
class="comparison-card group"
>
<div class="card-content">

View file

@ -29,7 +29,7 @@ const comparisonSchema = {
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://picture.com/comparisons/${data.slug}`,
'@id': `https://picture.com/comparisons/${comparison.id}`,
},
...(data.coverImage && {
image: {
@ -90,7 +90,7 @@ const breadcrumbSchema = {
'@type': 'ListItem',
position: 3,
name: data.title,
item: `https://picture.com/comparisons/${data.slug}`,
item: `https://picture.com/comparisons/${comparison.id}`,
},
],
};

View file

@ -1,6 +1,5 @@
---
import type { CollectionEntry } from 'astro:content';
import { marked } from 'marked';
interface Props {
faq: CollectionEntry<'faq'>;
@ -42,7 +41,7 @@ const { Content } = await faq.render();
}
.faq-item:hover {
@apply border-dark-hover;
@apply border-border-hover;
}
.faq-question {

View file

@ -53,7 +53,7 @@ import { formatCategoryName } from '../../utils/promptTemplates';
{
difficulties.map((diff) => (
<option value={diff}>
{diff.charAt(0).toUpperCase() + diff.slice(1)} ({stats.byDifficulty[diff]})
{diff.charAt(0).toUpperCase() + diff.slice(1)} ({stats.byDifficulty[diff as keyof typeof stats.byDifficulty]})
</option>
))
}

View file

@ -18,7 +18,7 @@ const difficultyColor = getDifficultyColor(data.difficulty);
---
<a
href={`/tutorials/${data.slug}`}
href={`/tutorials/${tutorial.id}`}
class="tutorial-card group"
data-difficulty={data.difficulty}
>

View file

@ -11,7 +11,7 @@ const { data } = useCase;
---
<a
href={`/use-cases/${data.slug}`}
href={`/use-cases/${useCase.id}`}
class="use-case-card group"
>
<div class="card-content">

View file

@ -1,6 +0,0 @@
---
title: "Picture vs Traditional Design"
order: 1
---
Compare Picture's AI-powered approach with traditional design workflows and see how you can save time and resources.

View file

@ -1,6 +0,0 @@
---
title: "What is Picture?"
order: 1
---
Picture is an AI-powered image generation platform that helps you create stunning visuals from text descriptions.

View file

@ -1,7 +0,0 @@
---
title: "Marketing & Social Media"
icon: "📱"
order: 1
---
Create eye-catching visuals for your social media campaigns and marketing materials with AI-generated images.

View file

@ -1,4 +1,5 @@
---
import '../styles/global.css';
// import { HeadHrefLangs } from 'astro-i18next/components';
import { t } from '../i18n';
import LanguageSwitcher from '@components/LanguageSwitcher.astro';

View file

@ -11,7 +11,7 @@ import BlogCard from '@components/blog/BlogCard.astro';
export async function getStaticPaths() {
const allPosts = await getCollection('blog');
return allPosts.map(post => ({
params: { slug: post.slug },
params: { slug: post.id },
props: { post },
}));
}

View file

@ -16,7 +16,7 @@ import {
export async function getStaticPaths() {
const changelog = await getCollection('changelog');
return changelog.map((entry) => ({
params: { slug: entry.slug },
params: { slug: entry.id },
props: { entry },
}));
}

View file

@ -64,7 +64,7 @@ const groupedByYearMonth = await getChangelogGroupedByYearMonth(language);
<p class="text-gray-300 text-lg">{latestRelease.data.summary}</p>
</div>
<a
href={`/changelog/${latestRelease.data.slug}`}
href={`/changelog/${latestRelease.id}`}
class="px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition whitespace-nowrap"
>
Read Full Release

View file

@ -8,7 +8,7 @@ import { getWinnerBadgeColor, getWinnerBadgeText, calculateOverallWinner } from
export async function getStaticPaths() {
const comparisons = await getCollection('comparisons');
return comparisons.map((comparison) => ({
params: { slug: comparison.data.slug },
params: { slug: comparison.id },
props: { comparison },
}));
}
@ -28,8 +28,8 @@ const overallWinner = data.winnerBadge || calculateOverallWinner(data.comparison
const allComparisons = await getCollection('comparisons');
const relatedComparisons = allComparisons
.filter((c) =>
data.relatedComparisons.includes(c.data.slug) &&
c.data.slug !== data.slug &&
data.relatedComparisons.includes(c.id) &&
c.id !== comparison.id &&
c.data.language === data.language
)
.slice(0, 3);

View file

@ -9,14 +9,10 @@ import FeatureCard from '@components/features/FeatureCard.astro';
export async function getStaticPaths() {
const allFeatures = await getCollection('features');
return allFeatures.map(feature => {
// Remove language prefix from slug (e.g., "en/cross-platform-apps" -> "cross-platform-apps")
const slug = feature.slug.split('/').pop() || feature.slug;
return {
params: { slug },
return allFeatures.map(feature => ({
params: { slug: feature.id },
props: { feature },
};
});
}));
}
interface Props {

View file

@ -5,6 +5,92 @@ import Features from '@components/Features.astro';
import Testimonials from '@components/Testimonials.astro';
import CTA from '@components/CTA.astro';
import Footer from '@components/Footer.astro';
// Shared components
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
// Steps data
const steps = [
{
number: '1',
title: 'Describe Your Vision',
description: 'Enter a text prompt describing the image you want to create. Be as detailed or simple as you like.',
image: '/screenshots/prompt.png'
},
{
number: '2',
title: 'Choose Your Model',
description: 'Select from multiple AI models - FLUX Schnell for speed, Dev for balance, or Pro for maximum quality.',
image: '/screenshots/model.png'
},
{
number: '3',
title: 'Generate & Download',
description: 'Watch as your image is created in seconds. Download in high resolution or save to your gallery.',
image: '/screenshots/download.png'
}
];
// Pricing preview data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/month',
description: 'Perfect for trying out Picture',
features: [
{ text: '100 images/month', included: true },
{ text: 'FLUX Schnell model', included: true },
{ text: 'Basic editing tools', included: true },
{ text: 'Gallery storage (30 days)', included: true },
{ text: 'Pro models', included: false },
{ text: 'Priority generation', included: false }
],
cta: {
text: 'Get Started Free',
href: '#'
}
},
{
name: 'Pro',
price: '19',
period: '/month',
description: 'For professionals & creators',
features: [
{ text: '2,000 images/month', included: true },
{ text: 'All AI models', included: true },
{ text: 'Advanced editing', included: true },
{ text: 'Unlimited storage', included: true },
{ text: 'Priority queue', included: true },
{ text: 'Commercial usage', included: true }
],
cta: {
text: 'Start Free Trial',
href: '#'
},
highlighted: true,
badge: 'Most Popular'
},
{
name: 'Enterprise',
price: 'Custom',
period: '',
description: 'For teams & businesses',
features: [
{ text: 'Unlimited images', included: true },
{ text: 'All models + early access', included: true },
{ text: 'Custom fine-tuning', included: true },
{ text: 'API access', included: true },
{ text: 'Team collaboration', included: true },
{ text: 'SLA guarantee', included: true }
],
cta: {
text: 'Contact Sales',
href: '#'
}
}
];
---
<Layout
@ -13,6 +99,25 @@ import Footer from '@components/Footer.astro';
>
<Hero />
<Features />
<StepsSection
id="how-it-works"
title="Create Stunning Images in 3 Steps"
subtitle="From text to image in seconds - no design skills required"
steps={steps}
showImages={false}
alternateLayout={true}
class="bg-dark-surface"
/>
<PricingSection
id="pricing"
title="Simple, Transparent Pricing"
subtitle="Start for free, upgrade as you grow. No hidden fees."
plans={pricingPlans}
class="bg-dark-bg"
/>
<Testimonials />
<CTA />
<Footer />

View file

@ -239,7 +239,7 @@ const models = [
</span>
<span class="feature-text">
{feature.text}
{feature.note && <span class="feature-note"> ({feature.note})</span>}
{'note' in feature && feature.note && <span class="feature-note"> ({feature.note})</span>}
</span>
</li>
))}

View file

@ -14,7 +14,7 @@ import { t } from '../../i18n';
export const getStaticPaths = (async () => {
const templates = await getCollection('promptTemplates');
return templates.map((template) => ({
params: { slug: template.slug },
params: { slug: template.id },
props: { template },
}));
}) satisfies GetStaticPaths;

View file

@ -120,7 +120,7 @@ const allModels = [
{
difficulties.map((diff) => (
<option value={diff}>
{diff.charAt(0).toUpperCase() + diff.slice(1)} ({stats.byDifficulty[diff]})
{diff.charAt(0).toUpperCase() + diff.slice(1)} ({stats.byDifficulty[diff as keyof typeof stats.byDifficulty]})
</option>
))
}
@ -180,7 +180,7 @@ const allModels = [
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{featuredTemplates.map((template) => (
<a
href={`/prompt-templates/${template.slug}`}
href={`/prompt-templates/${template.id}`}
class="group bg-white border border-gray-200 rounded-xl overflow-hidden hover:shadow-xl transition-all duration-300 hover:-translate-y-1"
>
{/* Template Card */}

View file

@ -15,7 +15,7 @@ import {
export async function getStaticPaths() {
const tutorials = await getCollection('tutorials');
return tutorials.map((tutorial) => ({
params: { slug: tutorial.slug },
params: { slug: tutorial.id },
props: { tutorial },
}));
}

View file

@ -0,0 +1,84 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Picture Theme CSS Variables - Indigo/Violet (Dark Theme) */
:root {
/* Primary colors - Picture Indigo */
--color-primary: #818cf8;
--color-primary-hover: #6366f1;
--color-primary-glow: rgba(129, 140, 248, 0.3);
/* Text colors (Dark theme) */
--color-text-primary: #ffffff;
--color-text-secondary: #d1d5db;
--color-text-muted: #9ca3af;
/* Background colors (Dark theme) */
--color-background-page: #000000;
--color-background-card: #1a1a1a;
--color-background-card-hover: #242424;
/* Border colors */
--color-border: #383838;
--color-border-hover: #4f4f4f;
}
/* Base styles */
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--color-background-page);
color: var(--color-text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Selection */
::selection {
background-color: var(--color-primary);
color: white;
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.6s ease-out forwards;
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-gradient-to-r from-primary-600 to-secondary-600 rounded-lg hover:from-primary-700 hover:to-secondary-700 transition-all duration-300 shadow-lg;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-dark-elevated border border-dark-border rounded-lg hover:border-primary/50 transition-all duration-300;
}
}

View file

@ -42,7 +42,7 @@ export async function getRelatedPosts(
const allPosts = await getBlogPosts(post.data.language);
// Filter out current post
const otherPosts = allPosts.filter(p => p.slug !== post.slug);
const otherPosts = allPosts.filter(p => p.id !== post.id);
// Score posts by relevance (same category, shared tags)
const scoredPosts = otherPosts.map(p => {

View file

@ -138,7 +138,7 @@ export async function getRelatedCaseStudies(
const caseStudies = await getAllCaseStudies();
// Filter out current case study
const others = caseStudies.filter((cs) => cs.data.slug !== currentCaseStudy.data.slug);
const others = caseStudies.filter((cs) => cs.id !== currentCaseStudy.id);
// Score based on similarity
const scored = others.map((cs) => {
@ -176,7 +176,7 @@ export async function getRelatedCaseStudies(
score += sharedModels.length;
// Explicit related case studies = +20 points
if (currentCaseStudy.data.relatedCaseStudies.includes(cs.data.slug)) {
if (currentCaseStudy.data.relatedCaseStudies.includes(cs.id)) {
score += 20;
}
@ -215,7 +215,7 @@ export async function searchCaseStudies(query: string): Promise<CaseStudyEntry[]
*/
export async function getCaseStudyBySlug(slug: string): Promise<CaseStudyEntry | undefined> {
const caseStudies = await getAllCaseStudies();
return caseStudies.find((cs) => cs.data.slug === slug);
return caseStudies.find((cs) => cs.id === slug || cs.id.replace('en/', '') === slug);
}
/**

View file

@ -43,13 +43,13 @@ export async function getRelatedFeatures(
// Filter out current feature and same category
const relatedFeatures = allFeatures
.filter(f => f.slug !== feature.slug && f.data.category === feature.data.category)
.filter(f => f.id !== feature.id && f.data.category === feature.data.category)
.slice(0, limit);
// If not enough, add from other categories
if (relatedFeatures.length < limit) {
const remaining = allFeatures
.filter(f => f.slug !== feature.slug && !relatedFeatures.includes(f))
.filter(f => f.id !== feature.id && !relatedFeatures.includes(f))
.slice(0, limit - relatedFeatures.length);
relatedFeatures.push(...remaining);
}

View file

@ -185,12 +185,12 @@ export async function getRelatedTutorials(
const allTutorials = await getTutorials(tutorial.data.language);
// Filter out current tutorial
const otherTutorials = allTutorials.filter((t) => t.slug !== tutorial.slug);
const otherTutorials = allTutorials.filter((t) => t.id !== tutorial.id);
// Get tutorials from related slugs
const relatedSlugs = tutorial.data.relatedTutorials;
const relatedBySlug = otherTutorials.filter((t) =>
relatedSlugs.includes(t.data.slug)
relatedSlugs.includes(t.id)
);
// Get tutorials from same category

View file

@ -3,9 +3,30 @@ import preset from '@picture/design-tokens/tailwind/preset';
/** @type {import('tailwindcss').Config} */
export default {
presets: [preset],
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
],
theme: {
extend: {}
extend: {
colors: {
// CSS variable mappings for shared-landing-ui compatibility
background: {
page: 'var(--color-background-page, #000000)',
card: 'var(--color-background-card, #1a1a1a)',
'card-hover': 'var(--color-background-card-hover, #242424)'
},
text: {
primary: 'var(--color-text-primary, #ffffff)',
secondary: 'var(--color-text-secondary, #d1d5db)',
muted: 'var(--color-text-muted, #9ca3af)'
},
border: {
DEFAULT: 'var(--color-border, #383838)',
hover: 'var(--color-border-hover, #4f4f4f)'
}
}
}
},
plugins: [
require('@tailwindcss/typography')