Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ Cyanotype is a privacy-first, client-side web application designed to transform
- **Individual Negative Export**:
- Export as JPEG (with configurable 10–100% compression) or lossless PNG.
- Saves directly to a chosen folder using the File System Access API or bundles into a ZIP archive.
- **Process Guides & Documentation (`/guide`)**:
- Step-by-step development workflow (coating, drying, UV exposure, water rinse, oxidation with $H_2O_2$, toning).
- Tested laboratory chemical formulations (Solution A, Solution B, 1:1 mix + 2% oxalic acid boost, small-batch workshop recipe).
- Historical context (Sir John Herschel 1842, Anna Atkins' British Algae, blueprint origins, alternative photographic comparison).
- **SEO & Discoverability**:
- Automated XML sitemap generation (`/sitemap.xml`) and robots.txt (`/robots.txt`).
- Full static HTML prerendering for search engine crawlers and social share previews.

---

## Tech Stack

- **Framework**: [Nuxt 4](https://nuxt.com/) (Vue 3, TypeScript, SSR-disabled static target)
- **Framework**: [Nuxt 4](https://nuxt.com/) (Vue 3, TypeScript, Nitro static prerendering)
- **Content Engine**: [@nuxt/content v3](https://content.nuxt.com/) (Markdown collections with Zod schema validation)
- **SEO**: [@nuxtjs/seo](https://nuxtseo.com/) (automated sitemap, robots.txt, meta tags, schema.org)
- **UI Components**: [@nuxt/ui v4](https://ui.nuxt.com/) & [Tailwind CSS v4](https://tailwindcss.com/)
- **PDF & Archive Generation**: [jsPDF](https://github.com/parallax/jsPDF) & [JSZip](https://stuk.github.io/jszip/)
- **Package Manager & Runtime**: [Bun](https://bun.sh/)
Expand Down
25 changes: 15 additions & 10 deletions app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,69 @@ export default defineAppConfig({
primary: 'violet',
neutral: 'neutral'
},
card: {
defaultVariants: {
variant: 'outline'
}
},
input: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
select: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
textarea: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
selectMenu: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
inputMenu: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
inputNumber: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
inputTags: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
inputDate: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
inputTime: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
},
pinInput: {
defaultVariants: {
variant: 'soft',
color: 'neutral'
color: 'secondary'
}
}
}
Expand Down
68 changes: 58 additions & 10 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,49 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'
import { cs, en } from '@nuxt/ui/locale'

const route = useRoute()
const { t, locale } = useI18n()

const locales = { cs, en }
const currentLocale = computed(() => locales[locale.value as 'cs' | 'en'] || en)

const headerItems = computed<NavigationMenuItem[]>(() => [
{
label: t('nav.creator'),
icon: 'i-lucide-image',
to: '/',
active: route.path === '/'
},
{
label: t('nav.guide'),
icon: 'i-lucide-book-open',
to: '/guide',
active: route.path.startsWith('/guide')
}
])

useHead({
title: 'Cyanotype | Negatives Creator',
title: computed(() => t('app.title')),
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
name: 'description',
content: 'Client-side digital negative generator for cyanotype alternative photography process. Transform photos to grayscale negatives at 300 DPI.'
content: computed(() => t('app.description'))
}
],
link: [{ rel: 'icon', href: '/favicon.ico' }],
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
],
htmlAttrs: {
lang: 'en'
lang: computed(() => locale.value)
}
})
</script>

<template>
<UApp>
<UApp :locale="currentLocale">
<UHeader>
<template #left>
<NuxtLink
Expand All @@ -29,12 +56,33 @@ useHead({
class="w-5 h-5"
/>
</div>
<span class="font-bold text-lg tracking-tight">Cyanotype</span>
<span class="font-bold text-lg tracking-tight">{{ t('app.title') }}</span>
</NuxtLink>
</template>

<UNavigationMenu :items="headerItems" />

<template #right>
<UColorModeButton />
<div class="flex items-center gap-2">
<div class="hidden lg:flex">
<LanguageSelect />
</div>
<UColorModeButton />
</div>
</template>

<template #body>
<div class="space-y-4">
<UNavigationMenu
:items="headerItems"
orientation="vertical"
class="-mx-2.5"
/>
<div class="flex items-center justify-between pt-2 border-t border-default">
<span class="text-xs text-neutral-500">{{ t('nav.language') }}</span>
<LanguageSelect />
</div>
</div>
</template>
</UHeader>

Expand All @@ -47,7 +95,7 @@ useHead({
<UFooter>
<template #left>
<p class="text-xs text-neutral-500">
Cyanotype • Open-source project created by <a
{{ t('footer.text') }} <a
href="https://github.com/FelyCZ"
target="_blank"
rel="noopener noreferrer"
Expand All @@ -61,11 +109,11 @@ useHead({
to="https://github.com/FelyCZ/cyanotype"
target="_blank"
icon="i-simple-icons-github"
label="GitHub"
:label="t('footer.github')"
color="neutral"
variant="ghost"
size="xs"
aria-label="GitHub repository"
:aria-label="t('footer.githubAria')"
/>
</template>
</UFooter>
Expand Down
18 changes: 10 additions & 8 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,24 @@
}

:root {
--ui-radius: 0.5rem;
--ui-radius: 0.375rem;
}

:root, .light {
--ui-bg-muted: var(--ui-color-neutral-100);
--ui-bg-elevated: var(--ui-color-neutral-100);
--ui-bg-accented: var(--ui-color-neutral-200);
--ui-text: var(--ui-color-neutral-900);
--ui-text: var(--ui-color-neutral-700);
--ui-text-highlighted: var(--ui-color-neutral-950);
--ui-primary: var(--ui-color-primary-500);
}

.dark {
--ui-bg: var(--ui-color-neutral-950);
--ui-bg-muted: var(--ui-color-neutral-900);
--ui-bg-elevated: var(--ui-color-neutral-900);
--ui-bg-accented: var(--ui-color-neutral-800);
--ui-text: var(--ui-color-neutral-100);
--ui-text-highlighted: var(--ui-color-neutral-50);
--ui-bg: var(--ui-color-neutral-900);
--ui-bg-muted: var(--ui-color-neutral-800);
--ui-bg-elevated: var(--ui-color-neutral-800);
--ui-bg-accented: var(--ui-color-neutral-700);
--ui-text: var(--ui-color-neutral-300);
--ui-text-highlighted: #ffffff;
--ui-primary: var(--ui-color-primary-400);
}
23 changes: 12 additions & 11 deletions app/components/ActionBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ const emit = defineEmits<{
'dismissAlert': []
}>()

const { t } = useI18n()

const summaryText = computed(() => {
if (props.photos.length === 0) return 'No images selected'
const count = props.photos.length
return `${count} image${count > 1 ? 's' : ''} loaded!`
return count === 0 ? t('actions.noImages') : t('actions.imagesLoaded', count)
})
</script>

Expand Down Expand Up @@ -67,7 +68,7 @@ const summaryText = computed(() => {
name="i-lucide-loader"
class="w-4 h-4 animate-spin text-primary"
/>
{{ exportStatusText || (isPreviewing ? 'Generating preview pages...' : 'Exporting negatives...') }}
{{ exportStatusText || (isPreviewing ? t('actions.renderingPreview') : t('actions.exportingNegatives')) }}
</span>
<span class="text-neutral-500">{{ Math.round(exportProgress) }}%</span>
</div>
Expand All @@ -90,10 +91,10 @@ const summaryText = computed(() => {
<span>{{ summaryText }}</span>
</div>

<!-- Action Buttons (wrapping onto multiple lines on mobile when space is limited) -->
<!-- Action Buttons -->
<div class="flex flex-wrap items-center gap-2.5 w-full sm:w-auto justify-start sm:justify-end">
<UButton
label="Clear All"
:label="t('actions.clearAll')"
color="neutral"
variant="ghost"
icon="i-lucide-trash"
Expand All @@ -102,7 +103,7 @@ const summaryText = computed(() => {
/>

<UButton
label="Preview pages"
:label="t('actions.previewPdf')"
color="neutral"
variant="subtle"
size="lg"
Expand All @@ -113,7 +114,7 @@ const summaryText = computed(() => {
/>

<UButton
label="Save All Negatives"
:label="t('actions.saveAll')"
color="primary"
size="lg"
icon="i-lucide-download"
Expand All @@ -125,7 +126,7 @@ const summaryText = computed(() => {
</div>
</UCard>

<!-- Global Preview Mode Switch: Negatives / Cyanotype (Centered under the card) -->
<!-- Global Preview Mode Switch: Negatives / Cyanotype -->
<div
v-if="photos.length > 0"
class="flex justify-center items-center pt-1 pb-1"
Expand All @@ -137,14 +138,14 @@ const summaryText = computed(() => {
:class="previewMode === 'negative' ? 'text-neutral-950 dark:text-white font-semibold' : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'"
@click="emit('update:previewMode', 'negative')"
>
Negatives
{{ t('actions.negative') }}
</button>

<USwitch
:model-value="previewMode === 'cyanotype'"
size="sm"
color="primary"
aria-label="Toggle between negatives and cyanotype thumbnail preview"
:aria-label="t('actions.togglePreviewAria')"
@update:model-value="(val) => emit('update:previewMode', val ? 'cyanotype' : 'negative')"
/>

Expand All @@ -155,7 +156,7 @@ const summaryText = computed(() => {
@click="emit('update:previewMode', 'cyanotype')"
>
<span class="w-2.5 h-2.5 rounded-full bg-[#1C39BB]" />
<span>Cyanotype</span>
<span>{{ t('actions.cyanotype') }}</span>
</button>
</div>
</div>
Expand Down
Loading
Loading