Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.git
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json /app/
RUN npm ci

COPY . /app
RUN npm run build

FROM nginx:1.27-alpine

COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 5173

CMD ["nginx", "-g", "daemon off;"]
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,19 @@ Then open the Vite dev server URL (usually `http://localhost:5173`).

- Embeddings are computed client-side via `@huggingface/transformers` using `Xenova/clip-vit-base-patch32`.
- PCA is computed client-side with `ml-pca`.
- Input hardening:
- text input is capped at 280 characters,
- image input only accepts `jpg/jpeg/png`,
- image file size is limited to 3 MB.
- This is a prototype UI: no backend, no persistence (refresh clears lilies), no auth.
- Water background uses a tileable image: `public/water_tileable.jpg`.

## Deployment Notes

- The production Docker image builds static assets (`npm run build`) and serves them via Nginx.
- Security headers (including CSP, frame restrictions, and content type hardening) are set in `nginx/default.conf`.
- Inputs stay browser-local: uploaded images are represented as object URLs and are not sent to a backend by this app.

## Project Structure

- `src/App.vue` main state + orchestration (create/grow/plant/select)
Expand Down
17 changes: 17 additions & 0 deletions nginx/default.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
server {
listen 5173;
server_name _;

root /usr/share/nginx/html;
index index.html;

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob: https:; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https:; font-src 'self' data: https:; worker-src 'self' blob: https:; connect-src 'self' blob: data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always;

location / {
try_files $uri $uri/ /index.html;
}
}
64 changes: 62 additions & 2 deletions src/components/LilyCreator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@
v-if="mode === 'text'"
v-model="textInput"
:placeholder="t('creator.placeholder')"
:maxlength="TEXT_MAX_CHARS"
rows="6"
></textarea>
<p v-if="mode === 'text'" class="input-meta">{{ t('creator.textLimit', { count: textLength, max: TEXT_MAX_CHARS }) }}</p>
<div v-else class="image-input">
<input type="file" accept="image/*" @change="handleImage" />
<input type="file" accept=".jpg,.jpeg,.png,image/jpeg,image/png" @change="handleImage" />
<p class="input-meta">{{ t('creator.imageLimit', { maxSizeMb: IMAGE_MAX_SIZE_MB, formats: ALLOWED_IMAGE_EXTENSIONS_LABEL }) }}</p>
<p v-if="inputError" class="input-error" role="alert">{{ inputError }}</p>
<div v-if="imagePreview" class="preview">
<img :src="imagePreview" :alt="t('creator.imagePreviewAlt')" />
</div>
Expand Down Expand Up @@ -117,7 +121,7 @@
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from './IconButton.vue'
import { createLilyPadPaths } from '../utils/lilyPad'
Expand Down Expand Up @@ -150,6 +154,13 @@ const mode = ref<'text' | 'image'>('text')
const textInput = ref('')
const imageData = ref('')
const imagePreview = ref('')
const inputError = ref('')

const TEXT_MAX_CHARS = 280
const IMAGE_MAX_SIZE_MB = 3
const IMAGE_MAX_BYTES = IMAGE_MAX_SIZE_MB * 1024 * 1024
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png'])
const ALLOWED_IMAGE_EXTENSIONS_LABEL = 'JPG, JPEG, PNG'

type Phase = 'idle' | 'embedding' | 'ready'
const phase = ref<Phase>('idle')
Expand All @@ -165,6 +176,8 @@ const showStatusDelayMs = computed(() => (isFirstLily.value ? 260 : 120))
const streamRunId = ref(0)
const streamValues = ref<string[]>([])

const textLength = computed(() => textInput.value.trim().length)

const canGrow = computed(() => {
if (mode.value === 'text') return textInput.value.trim().length > 0
return imageData.value.length > 0
Expand Down Expand Up @@ -233,11 +246,38 @@ const handleImage = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
inputError.value = ''

if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
resetImageInput(target)
inputError.value = t('creator.errors.invalidImageType', { formats: ALLOWED_IMAGE_EXTENSIONS_LABEL })
return
}

if (file.size > IMAGE_MAX_BYTES) {
resetImageInput(target)
inputError.value = t('creator.errors.imageTooLarge', { maxSizeMb: IMAGE_MAX_SIZE_MB })
return
}

revokeObjectUrl(imageData.value)
const objectUrl = URL.createObjectURL(file)
imageData.value = objectUrl
imagePreview.value = objectUrl
}

const resetImageInput = (target: HTMLInputElement) => {
revokeObjectUrl(imageData.value)
imageData.value = ''
imagePreview.value = ''
target.value = ''
}

const revokeObjectUrl = (url: string) => {
if (!url) return
URL.revokeObjectURL(url)
}

const primaryButtonLabel = computed(() => {
if (phase.value === 'ready') return t('creator.plantButton')
if (props.isLoading || phase.value !== 'idle') return t('creator.growingButton')
Expand Down Expand Up @@ -304,4 +344,24 @@ watch(
}
)

watch(
() => mode.value,
() => {
inputError.value = ''
}
)

watch(
() => textInput.value,
(value) => {
if (value.length > TEXT_MAX_CHARS) {
textInput.value = value.slice(0, TEXT_MAX_CHARS)
}
}
)

onBeforeUnmount(() => {
revokeObjectUrl(imageData.value)
})

</script>
6 changes: 6 additions & 0 deletions src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ export default {
imagePreviewAlt: 'Vorschau',
helperStart: 'Zum Start wähle',
helperOr: 'oder',
textLimit: '{count}/{max} Zeichen',
imageLimit: 'Erlaubt: {formats} bis {maxSizeMb} MB',
growButton: 'Wachsen lassen! 🪷',
growingButton: 'Wächst ... 🪷',
plantButton: 'Einpflanzen! 🪷',
errors: {
invalidImageType: 'Ungültiges Bildformat. Bitte nutze {formats}.',
imageTooLarge: 'Das Bild ist zu groß. Maximale Dateigröße: {maxSizeMb} MB.',
},
idle: {
title: 'Lass aus deinen Daten eine Seerose wachsen',
condensedMeaning: 'Liste aus Zahlen, die die Bedeutung deiner Daten beinhaltet',
Expand Down
6 changes: 6 additions & 0 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ export default {
imagePreviewAlt: 'Preview',
helperStart: 'To begin, choose',
helperOr: 'or',
textLimit: '{count}/{max} characters',
imageLimit: 'Allowed: {formats} up to {maxSizeMb} MB',
growButton: 'Grow it! 🪷',
growingButton: 'Growing... 🪷',
plantButton: 'Plant it! 🪷',
errors: {
invalidImageType: 'Invalid image format. Please use {formats}.',
imageTooLarge: 'Image is too large. Maximum file size is {maxSizeMb} MB.',
},
idle: {
title: 'Grow a waterlily from your data',
condensedMeaning: '"condensed meaning"',
Expand Down
13 changes: 13 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,19 @@ button {
margin-bottom: 12px;
}

.input-meta {
margin: 2px 0 10px;
font-size: 0.84rem;
color: rgba(10, 45, 65, 0.72);
}

.input-error {
margin: 0 0 12px;
color: #8a1f1f;
font-weight: 600;
font-size: 0.88rem;
}

.image-input .preview {
display: grid;
place-items: center;
Expand Down