@nomideusz/svelte-media 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ Backfilled 2026-08-02 from git history. Entries before that date are
4
+ reconstructed from commits, so they record what changed rather than a release
5
+ that was tagged at the time. The package has not been published to npm yet.
6
+
7
+ ## Unreleased
8
+
9
+ ### Added
10
+ - README — the package's first, documenting the adapter seam, the four sizes,
11
+ the `(prefix, entityId, filename)` key shape the helpers take, and the fact
12
+ that all output is WebP regardless of what was uploaded.
13
+ - `sideEffects: false`, so consumers can tree-shake.
14
+
15
+ ## 0.1.1 — 2026-07-07
16
+
17
+ ### Fixed
18
+ - Ship TypeScript-free `.svelte` files (vitePreprocess script pass), so
19
+ consumers without a TS setup can use the components.
20
+
21
+ ## 0.1.0 — 2026-04-01
22
+
23
+ ### Added
24
+ - S3-compatible storage adapter (`createS3Adapter`) — R2, MinIO, Tigris, AWS —
25
+ replacing an earlier imgproxy + local-filesystem arrangement.
26
+ - Local disk adapter (`createLocalAdapter`).
27
+ - `processAndStore`: validate, resize to thumbnail/medium/large with sharp, and
28
+ write every size through the adapter.
29
+ - `ImageUpload` and `ImageGallery` components.
30
+
31
+ ### Note
32
+ Presigned URLs are used for S3 reads, since Railway/Tigris buckets are
33
+ private-only.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bartosz Dymet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # @nomideusz/svelte-media
2
+
3
+ Image upload, resizing and multi-size storage for Svelte 5 apps. One call takes
4
+ a `File`, validates it, renders four sizes with sharp, and writes them through a
5
+ pluggable storage adapter — S3-compatible or local disk.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @nomideusz/svelte-media
11
+ ```
12
+
13
+ > Requires Svelte 5 (`^5.0.0`). The processing path is server-only: it uses
14
+ > `sharp` and Node `Buffer`. Import `processAndStore` from server code
15
+ > (`+page.server.ts`, a route handler, a script) — never from a component.
16
+
17
+ ## Why
18
+
19
+ Every app that accepts images rewrites the same four steps: validate, resize to
20
+ a set of named sizes, upload each one, and hand back the keys. The variable part
21
+ is only *where* the bytes go. This package fixes the pipeline and makes storage
22
+ the seam.
23
+
24
+ ## Quick Start
25
+
26
+ ```ts
27
+ import { createS3Adapter, processAndStore, getMediaUrl } from '@nomideusz/svelte-media';
28
+
29
+ const storage = createS3Adapter({
30
+ endpoint: process.env.S3_ENDPOINT!, // https://xxx.r2.cloudflarestorage.com
31
+ region: 'auto', // 'auto' for R2, 'us-east-1' for AWS
32
+ bucket: process.env.S3_BUCKET!,
33
+ accessKeyId: process.env.S3_KEY!,
34
+ secretAccessKey: process.env.S3_SECRET!,
35
+ publicUrl: process.env.S3_PUBLIC_URL!, // https://pub-xxx.r2.dev
36
+ forcePathStyle: true, // true for MinIO/R2, false for Railway/AWS
37
+ });
38
+
39
+ const stored = await processAndStore(storage, file, 'tours', tourId);
40
+ // { filename, originalName, prefix, entityId, sizes: { original, thumbnail, medium, large } }
41
+
42
+ const src = getMediaUrl(storage, stored.prefix, stored.entityId, stored.filename, 'medium');
43
+ ```
44
+
45
+ Store `stored` on your row — the helpers below all take
46
+ `(prefix, entityId, filename)`, so keep those three. Nothing in the package
47
+ touches a database.
48
+
49
+ **Output is always WebP**, whatever was uploaded; `generateMediaKey` names every
50
+ file `<cuid2>.webp` and ignores the original extension.
51
+
52
+ ## Sizes
53
+
54
+ `processAndStore` always writes four variants:
55
+
56
+ | Size | Dimensions | Fit | Key prefix |
57
+ |---|---|---|---|
58
+ | `original` | unchanged | — | *(none)* |
59
+ | `thumbnail` | 300×300 | `cover` | `thumb_` |
60
+ | `medium` | 800×600 | `inside` | `med_` |
61
+ | `large` | 1200×900 | `inside` | `lg_` |
62
+
63
+ `cover` crops to fill; `inside` fits within the box and preserves aspect ratio,
64
+ so `medium` and `large` are upper bounds rather than exact dimensions. The table
65
+ is exported as `IMAGE_SIZES`.
66
+
67
+ ## Storage adapters
68
+
69
+ The seam is three methods:
70
+
71
+ ```ts
72
+ interface StorageAdapter {
73
+ put(key: string, buffer: Buffer, contentType: string): Promise<void>;
74
+ delete(key: string): Promise<void>;
75
+ getUrl(key: string): string;
76
+ }
77
+ ```
78
+
79
+ Two ship with the package:
80
+
81
+ ```ts
82
+ import { createS3Adapter, createLocalAdapter } from '@nomideusz/svelte-media';
83
+
84
+ createS3Adapter({ /* S3Config, above */ }); // R2, MinIO, Tigris, AWS
85
+ createLocalAdapter({ root: '/data/images' }); // writes under root, mkdir -p
86
+ ```
87
+
88
+ Anything satisfying the interface works — write your own for a CDN or a test
89
+ double.
90
+
91
+ `createLocalAdapter`'s `getUrl` returns the storage key unchanged, so it is a
92
+ path relative to `root`, not a URL. Serve `root` yourself (a static route, or
93
+ `/uploads/[...key]`) and prefix as needed.
94
+
95
+ ## Validation
96
+
97
+ ```ts
98
+ import { validateImageFile } from '@nomideusz/svelte-media';
99
+
100
+ const { valid, error } = validateImageFile(file, {
101
+ maxFileSize: 5 * 1024 * 1024, // default: 5 MB
102
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
103
+ });
104
+ ```
105
+
106
+ `processAndStore` runs this first and throws on failure, so calling it yourself
107
+ is only needed to reject early and report a friendlier message.
108
+
109
+ ## Deleting
110
+
111
+ ```ts
112
+ import { deleteMedia } from '@nomideusz/svelte-media';
113
+
114
+ // Removes all four sizes; individual failures are swallowed
115
+ await deleteMedia(storage, stored.prefix, stored.entityId, stored.filename);
116
+ ```
117
+
118
+ ## Components
119
+
120
+ ```svelte
121
+ <script lang="ts">
122
+ import { ImageUpload, ImageGallery } from '@nomideusz/svelte-media';
123
+
124
+ // Your endpoint calls processAndStore and returns the StoredMedia
125
+ async function upload(file: File) {
126
+ const body = new FormData();
127
+ body.set('file', file);
128
+ return await (await fetch('/api/images', { method: 'POST', body })).json();
129
+ }
130
+ </script>
131
+
132
+ <ImageUpload onUpload={upload} maxFiles={5} onError={(m) => toast(m)} />
133
+
134
+ <ImageGallery
135
+ images={stored}
136
+ getUrl={(prefix, entityId, filename, size) => `/uploads/${prefix}/${entityId}/${filename}?s=${size}`}
137
+ size="thumbnail"
138
+ onDelete={(filename) => remove(filename)}
139
+ />
140
+ ```
141
+
142
+ `ImageUpload` handles drag-and-drop, selection and the uploading state, but the
143
+ bytes go to *your* endpoint — it never talks to storage, so credentials stay on
144
+ the server. `ImageGallery` takes a `getUrl` callback rather than an adapter for
145
+ the same reason: it renders on the client, where the adapter cannot go.
146
+
147
+ `ImageUpload` props: `onUpload` (required), `onError?`, `maxFiles = 1`,
148
+ `accept = 'image/jpeg,image/png,image/webp'`, `config?`.
149
+ `ImageGallery` props: `images`, `getUrl` (both required), `onDelete?`,
150
+ `size = 'thumbnail'`.
151
+
152
+ ## API
153
+
154
+ ```ts
155
+ // Adapters
156
+ createS3Adapter(config: S3Config): StorageAdapter
157
+ createLocalAdapter(config: LocalConfig): StorageAdapter
158
+
159
+ // Pipeline
160
+ processAndStore(adapter, file, prefix, entityId, config?): Promise<StoredMedia>
161
+ deleteMedia(adapter, prefix, entityId, filename): Promise<void>
162
+ getMediaUrl(adapter, prefix, entityId, filename, size = 'medium'): string
163
+ getStorageKey(prefix, entityId, filename, size): string // `${prefix}/${entityId}/${sizePrefix}${filename}`
164
+ validateImageFile(file, config?): ValidationResult
165
+ generateMediaKey(): string // `<cuid2>.webp`
166
+ IMAGE_SIZES
167
+
168
+ // Components
169
+ ImageUpload, ImageGallery
170
+ ```
171
+
172
+ Types: `StorageAdapter`, `S3Config`, `LocalConfig`, `StoredMedia`, `ImageSize`,
173
+ `MediaConfig`, `ValidationResult`.
174
+
175
+ ## Development
176
+
177
+ ```bash
178
+ pnpm install
179
+ pnpm check # Typecheck
180
+ pnpm test # Vitest
181
+ pnpm run package # Build the library
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,143 @@
1
+ <script lang="ts">let { images, getUrl, onDelete, size = "thumbnail" } = $props();
2
+ let expanded = $state(null);
3
+ </script>
4
+
5
+ {#if images.length > 0}
6
+ <div class="asini-gallery">
7
+ {#each images as img (img.filename)}
8
+ <div class="asini-gallery__item">
9
+ <button
10
+ type="button"
11
+ class="asini-gallery__thumb-btn"
12
+ onclick={() => {
13
+ expanded = img;
14
+ }}
15
+ aria-label="View {img.originalName}"
16
+ >
17
+ <img
18
+ src={getUrl(
19
+ img.prefix,
20
+ img.entityId,
21
+ img.filename,
22
+ size,
23
+ )}
24
+ alt={img.originalName}
25
+ class="asini-gallery__thumb"
26
+ loading="lazy"
27
+ />
28
+ </button>
29
+ {#if onDelete}
30
+ <button
31
+ type="button"
32
+ class="asini-gallery__delete"
33
+ onclick={() => onDelete?.(img.filename)}
34
+ aria-label="Delete {img.originalName}">×</button
35
+ >
36
+ {/if}
37
+ </div>
38
+ {/each}
39
+ </div>
40
+ {/if}
41
+
42
+ {#if expanded}
43
+ <div
44
+ class="asini-lightbox"
45
+ role="dialog"
46
+ aria-modal="true"
47
+ aria-label="Image preview"
48
+ onclick={() => {
49
+ expanded = null;
50
+ }}
51
+ onkeydown={(e) => {
52
+ if (e.key === "Escape") expanded = null;
53
+ }}
54
+ tabindex="-1"
55
+ >
56
+ <div
57
+ class="asini-lightbox__img-wrap"
58
+ role="presentation"
59
+ onclick={(e) => {
60
+ e.stopPropagation();
61
+ }}
62
+ >
63
+ <img
64
+ src={getUrl(
65
+ expanded.prefix,
66
+ expanded.entityId,
67
+ expanded.filename,
68
+ "large",
69
+ )}
70
+ alt={expanded.originalName}
71
+ class="asini-lightbox__img"
72
+ />
73
+ </div>
74
+ </div>
75
+ {/if}
76
+
77
+ <style>
78
+ .asini-gallery {
79
+ display: flex;
80
+ flex-wrap: wrap;
81
+ gap: 0.5rem;
82
+ }
83
+ .asini-gallery__item {
84
+ position: relative;
85
+ display: inline-block;
86
+ }
87
+ .asini-gallery__thumb-btn {
88
+ display: block;
89
+ border: none;
90
+ padding: 0;
91
+ cursor: pointer;
92
+ border-radius: var(--asini-radius-sm);
93
+ overflow: hidden;
94
+ }
95
+ .asini-gallery__thumb {
96
+ display: block;
97
+ width: 100px;
98
+ height: 100px;
99
+ object-fit: cover;
100
+ border-radius: var(--asini-radius-sm);
101
+ border: 1px solid var(--asini-border);
102
+ }
103
+ .asini-gallery__delete {
104
+ position: absolute;
105
+ top: 2px;
106
+ right: 2px;
107
+ width: 20px;
108
+ height: 20px;
109
+ border-radius: 50%;
110
+ background: var(--asini-danger);
111
+ color: #fff;
112
+ border: none;
113
+ font-size: 14px;
114
+ line-height: 1;
115
+ cursor: pointer;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ }
120
+ .asini-lightbox {
121
+ position: fixed;
122
+ inset: 0;
123
+ background: rgba(0, 0, 0, 0.8);
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ z-index: 9999;
128
+ cursor: pointer;
129
+ }
130
+ .asini-lightbox__img-wrap {
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ outline: none;
135
+ }
136
+ .asini-lightbox__img {
137
+ max-width: 90vw;
138
+ max-height: 90vh;
139
+ object-fit: contain;
140
+ border-radius: var(--asini-radius);
141
+ cursor: default;
142
+ }
143
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { StoredMedia } from "../core/types.js";
2
+ interface Props {
3
+ images: StoredMedia[];
4
+ getUrl: (prefix: string, entityId: string, filename: string, size: "thumbnail" | "medium" | "large") => string;
5
+ onDelete?: (filename: string) => void;
6
+ size?: "thumbnail" | "medium";
7
+ }
8
+ declare const ImageGallery: import("svelte").Component<Props, {}, "">;
9
+ type ImageGallery = ReturnType<typeof ImageGallery>;
10
+ export default ImageGallery;
@@ -0,0 +1,107 @@
1
+ <script lang="ts">let {
2
+ onUpload,
3
+ onError,
4
+ maxFiles = 1,
5
+ accept = "image/jpeg,image/png,image/webp"
6
+ } = $props();
7
+ let dragging = $state(false);
8
+ let uploading = $state(false);
9
+ let input;
10
+ async function handleFiles(files) {
11
+ if (!files || files.length === 0) return;
12
+ uploading = true;
13
+ try {
14
+ for (const file of Array.from(files).slice(0, maxFiles)) {
15
+ await onUpload(file);
16
+ }
17
+ } catch (e) {
18
+ onError?.(e instanceof Error ? e.message : "Upload failed");
19
+ } finally {
20
+ uploading = false;
21
+ }
22
+ }
23
+ function onDrop(e) {
24
+ e.preventDefault();
25
+ dragging = false;
26
+ handleFiles(e.dataTransfer?.files ?? null);
27
+ }
28
+ </script>
29
+
30
+ <div
31
+ class="asini-upload"
32
+ class:asini-upload--drag={dragging}
33
+ class:asini-upload--busy={uploading}
34
+ role="region"
35
+ aria-label="Image upload"
36
+ ondragover={(e) => { e.preventDefault(); dragging = true; }}
37
+ ondragleave={() => { dragging = false; }}
38
+ ondrop={onDrop}
39
+ >
40
+ {#if uploading}
41
+ <span class="asini-upload__status">Uploading…</span>
42
+ {:else}
43
+ <button
44
+ type="button"
45
+ class="asini-upload__btn"
46
+ onclick={() => input.click()}
47
+ aria-label="Select image"
48
+ >
49
+ Choose image
50
+ </button>
51
+ <span class="asini-upload__hint">or drag and drop</span>
52
+ {/if}
53
+ <input
54
+ bind:this={input}
55
+ type="file"
56
+ {accept}
57
+ multiple={maxFiles > 1}
58
+ style="display:none"
59
+ onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
60
+ />
61
+ </div>
62
+
63
+ <style>
64
+ .asini-upload {
65
+ display: flex;
66
+ flex-direction: column;
67
+ align-items: center;
68
+ justify-content: center;
69
+ gap: 0.5rem;
70
+ padding: 2rem;
71
+ border: 2px dashed var(--asini-border);
72
+ border-radius: var(--asini-radius);
73
+ background: var(--asini-surface);
74
+ color: var(--asini-text-2);
75
+ transition: border-color 0.15s, background 0.15s;
76
+ cursor: pointer;
77
+ }
78
+ .asini-upload--drag {
79
+ border-color: var(--asini-accent);
80
+ background: var(--asini-accent-muted);
81
+ }
82
+ .asini-upload--busy {
83
+ opacity: 0.6;
84
+ pointer-events: none;
85
+ }
86
+ .asini-upload__btn {
87
+ padding: 0.5rem 1.25rem;
88
+ border: 1px solid var(--asini-border);
89
+ border-radius: var(--asini-radius-sm);
90
+ background: var(--asini-bg);
91
+ color: var(--asini-text);
92
+ font-size: 0.875rem;
93
+ cursor: pointer;
94
+ }
95
+ .asini-upload__btn:hover {
96
+ border-color: var(--asini-accent);
97
+ color: var(--asini-accent);
98
+ }
99
+ .asini-upload__hint {
100
+ font-size: 0.8rem;
101
+ color: var(--asini-text-3);
102
+ }
103
+ .asini-upload__status {
104
+ font-size: 0.875rem;
105
+ color: var(--asini-text-2);
106
+ }
107
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { StoredMedia, MediaConfig } from '../core/types.js';
2
+ interface Props {
3
+ onUpload: (file: File) => Promise<StoredMedia>;
4
+ onError?: (message: string) => void;
5
+ maxFiles?: number;
6
+ accept?: string;
7
+ config?: MediaConfig;
8
+ }
9
+ declare const ImageUpload: import("svelte").Component<Props, {}, "">;
10
+ type ImageUpload = ReturnType<typeof ImageUpload>;
11
+ export default ImageUpload;
@@ -0,0 +1,2 @@
1
+ import type { S3Config, StorageAdapter } from './types.js';
2
+ export declare function createS3Adapter(config: S3Config): StorageAdapter;
@@ -0,0 +1,33 @@
1
+ import { S3Client, PutObjectCommand, DeleteObjectCommand, } from '@aws-sdk/client-s3';
2
+ export function createS3Adapter(config) {
3
+ const client = new S3Client({
4
+ endpoint: config.endpoint,
5
+ region: config.region,
6
+ credentials: {
7
+ accessKeyId: config.accessKeyId,
8
+ secretAccessKey: config.secretAccessKey,
9
+ },
10
+ forcePathStyle: config.forcePathStyle ?? true,
11
+ });
12
+ return {
13
+ async put(key, buffer, contentType) {
14
+ await client.send(new PutObjectCommand({
15
+ Bucket: config.bucket,
16
+ Key: key,
17
+ Body: buffer,
18
+ ContentType: contentType,
19
+ // Keys are unique per upload, so objects never change in place.
20
+ CacheControl: 'public, max-age=31536000, immutable',
21
+ }));
22
+ },
23
+ async delete(key) {
24
+ await client.send(new DeleteObjectCommand({
25
+ Bucket: config.bucket,
26
+ Key: key,
27
+ }));
28
+ },
29
+ getUrl(key) {
30
+ return `${config.publicUrl.replace(/\/$/, '')}/${key}`;
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,6 @@
1
+ import type { StorageAdapter } from './types.js';
2
+ export interface LocalConfig {
3
+ /** Absolute path to the storage root directory, e.g. '/data/images' */
4
+ root: string;
5
+ }
6
+ export declare function createLocalAdapter(config: LocalConfig): StorageAdapter;
@@ -0,0 +1,17 @@
1
+ import { writeFile, unlink, mkdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ export function createLocalAdapter(config) {
4
+ return {
5
+ async put(key, buffer, contentType) {
6
+ const filePath = join(config.root, key);
7
+ await mkdir(dirname(filePath), { recursive: true });
8
+ await writeFile(filePath, buffer);
9
+ },
10
+ async delete(key) {
11
+ await unlink(join(config.root, key)).catch(() => { });
12
+ },
13
+ getUrl(key) {
14
+ return key;
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,24 @@
1
+ import type { StorageAdapter, StoredMedia, ImageSize, MediaConfig, ValidationResult } from './types.js';
2
+ export declare const IMAGE_SIZES: {
3
+ thumbnail: {
4
+ width: number;
5
+ height: number;
6
+ fit: "cover";
7
+ };
8
+ medium: {
9
+ width: number;
10
+ height: number;
11
+ fit: "inside";
12
+ };
13
+ large: {
14
+ width: number;
15
+ height: number;
16
+ fit: "inside";
17
+ };
18
+ };
19
+ export declare function validateImageFile(file: File, config?: MediaConfig): ValidationResult;
20
+ export declare function generateMediaKey(_originalName?: string): string;
21
+ export declare function getStorageKey(prefix: string, entityId: string, filename: string, size: ImageSize): string;
22
+ export declare function processAndStore(adapter: StorageAdapter, file: File, prefix: string, entityId: string, config?: MediaConfig): Promise<StoredMedia>;
23
+ export declare function deleteMedia(adapter: StorageAdapter, prefix: string, entityId: string, filename: string): Promise<void>;
24
+ export declare function getMediaUrl(adapter: StorageAdapter, prefix: string, entityId: string, filename: string, size?: ImageSize): string;
@@ -0,0 +1,76 @@
1
+ import sharp from 'sharp';
2
+ import { createId } from '@paralleldrive/cuid2';
3
+ const DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
4
+ const DEFAULT_ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
5
+ export const IMAGE_SIZES = {
6
+ thumbnail: { width: 300, height: 300, fit: 'cover' },
7
+ medium: { width: 800, height: 600, fit: 'inside' },
8
+ large: { width: 1200, height: 900, fit: 'inside' },
9
+ };
10
+ const SIZE_PREFIXES = {
11
+ original: '',
12
+ thumbnail: 'thumb_',
13
+ medium: 'med_',
14
+ large: 'large_',
15
+ };
16
+ const SIZE_QUALITY = {
17
+ original: 95,
18
+ thumbnail: 80,
19
+ medium: 85,
20
+ large: 90,
21
+ };
22
+ export function validateImageFile(file, config) {
23
+ const maxSize = config?.maxFileSize ?? DEFAULT_MAX_SIZE;
24
+ const allowed = config?.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
25
+ if (file.size > maxSize) {
26
+ const mb = Math.round(maxSize / 1024 / 1024);
27
+ return { valid: false, error: `File too large (max ${mb}MB)` };
28
+ }
29
+ if (!allowed.includes(file.type)) {
30
+ return { valid: false, error: `Invalid file type. Allowed: JPEG, PNG, WebP` };
31
+ }
32
+ return { valid: true };
33
+ }
34
+ export function generateMediaKey(_originalName) {
35
+ // All processed output is WebP regardless of the uploaded format.
36
+ return `${createId()}.webp`;
37
+ }
38
+ export function getStorageKey(prefix, entityId, filename, size) {
39
+ const sizePrefix = SIZE_PREFIXES[size];
40
+ return `${prefix}/${entityId}/${sizePrefix}${filename}`;
41
+ }
42
+ export async function processAndStore(adapter, file, prefix, entityId, config) {
43
+ const validation = validateImageFile(file, config);
44
+ if (!validation.valid)
45
+ throw new Error(validation.error);
46
+ const filename = generateMediaKey(file.name);
47
+ const arrayBuffer = await file.arrayBuffer();
48
+ const buffer = Buffer.from(arrayBuffer);
49
+ const sizes = {
50
+ original: getStorageKey(prefix, entityId, filename, 'original'),
51
+ thumbnail: getStorageKey(prefix, entityId, filename, 'thumbnail'),
52
+ medium: getStorageKey(prefix, entityId, filename, 'medium'),
53
+ large: getStorageKey(prefix, entityId, filename, 'large'),
54
+ };
55
+ const originalBuf = await sharp(buffer).rotate().webp({ quality: SIZE_QUALITY.original }).toBuffer();
56
+ await adapter.put(sizes.original, originalBuf, 'image/webp');
57
+ for (const size of ['thumbnail', 'medium', 'large']) {
58
+ const { width, height, fit } = IMAGE_SIZES[size];
59
+ const resized = await sharp(buffer)
60
+ .rotate()
61
+ .resize(width, height, { fit, withoutEnlargement: true })
62
+ .webp({ quality: SIZE_QUALITY[size] })
63
+ .toBuffer();
64
+ await adapter.put(sizes[size], resized, 'image/webp');
65
+ }
66
+ // ponytail: existing objects stay JPEG under their old keys — URLs derive
67
+ // from the stored filename, so old rows keep working without migration.
68
+ return { filename, originalName: file.name, prefix, entityId, sizes };
69
+ }
70
+ export async function deleteMedia(adapter, prefix, entityId, filename) {
71
+ const keys = ['original', 'thumbnail', 'medium', 'large'].map((size) => getStorageKey(prefix, entityId, filename, size));
72
+ await Promise.all(keys.map((key) => adapter.delete(key).catch(() => { })));
73
+ }
74
+ export function getMediaUrl(adapter, prefix, entityId, filename, size = 'medium') {
75
+ return adapter.getUrl(getStorageKey(prefix, entityId, filename, size));
76
+ }
@@ -0,0 +1,47 @@
1
+ export type ImageSize = 'original' | 'thumbnail' | 'medium' | 'large';
2
+ export interface StorageAdapter {
3
+ put(key: string, buffer: Buffer, contentType: string): Promise<void>;
4
+ delete(key: string): Promise<void>;
5
+ getUrl(key: string): string;
6
+ }
7
+ export interface S3Config {
8
+ /** Full endpoint URL, e.g. https://xxx.r2.cloudflarestorage.com */
9
+ endpoint: string;
10
+ /** Region string, e.g. 'auto' for R2 or 'us-east-1' for AWS */
11
+ region: string;
12
+ bucket: string;
13
+ accessKeyId: string;
14
+ secretAccessKey: string;
15
+ /** Base public URL for getUrl(), e.g. https://pub-xxx.r2.dev */
16
+ publicUrl: string;
17
+ /** Use path-style access (default: true for MinIO/R2, set false for Railway/AWS) */
18
+ forcePathStyle?: boolean;
19
+ }
20
+ export interface StoredMedia {
21
+ filename: string;
22
+ originalName: string;
23
+ /** Storage prefix, e.g. 'tours' or 'avatars' */
24
+ prefix: string;
25
+ /** Entity that owns this media, e.g. tourId or guideId */
26
+ entityId: string;
27
+ /** Storage keys for each size */
28
+ sizes: Record<ImageSize, string>;
29
+ }
30
+ export interface MediaValidationError {
31
+ isValid: false;
32
+ error: string;
33
+ }
34
+ export interface MediaValidationOk {
35
+ isValid: true;
36
+ }
37
+ export type MediaValidation = MediaValidationOk | MediaValidationError;
38
+ export declare const DEFAULT_MAX_SIZE: number;
39
+ export declare const DEFAULT_ALLOWED_TYPES: string[];
40
+ export interface ValidationResult {
41
+ valid: boolean;
42
+ error?: string;
43
+ }
44
+ export interface MediaConfig {
45
+ maxFileSize?: number;
46
+ allowedTypes?: string[];
47
+ }
@@ -0,0 +1,3 @@
1
+ // packages/svelte-media/src/lib/core/types.ts
2
+ export const DEFAULT_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
3
+ export const DEFAULT_ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
@@ -0,0 +1,7 @@
1
+ export type { StorageAdapter, S3Config, StoredMedia, ImageSize, MediaConfig, ValidationResult, } from './core/types.js';
2
+ export type { LocalConfig } from './core/local-adapter.js';
3
+ export { createS3Adapter } from './core/adapter.js';
4
+ export { createLocalAdapter } from './core/local-adapter.js';
5
+ export { validateImageFile, generateMediaKey, getStorageKey, processAndStore, deleteMedia, getMediaUrl, IMAGE_SIZES, } from './core/process.js';
6
+ export { default as ImageUpload } from './components/ImageUpload.svelte';
7
+ export { default as ImageGallery } from './components/ImageGallery.svelte';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createS3Adapter } from './core/adapter.js';
2
+ export { createLocalAdapter } from './core/local-adapter.js';
3
+ export { validateImageFile, generateMediaKey, getStorageKey, processAndStore, deleteMedia, getMediaUrl, IMAGE_SIZES, } from './core/process.js';
4
+ export { default as ImageUpload } from './components/ImageUpload.svelte';
5
+ export { default as ImageGallery } from './components/ImageGallery.svelte';
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@nomideusz/svelte-media",
3
+ "version": "0.1.0",
4
+ "description": "Image upload, processing, and S3-compatible storage for Svelte 5 apps.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "license": "MIT",
8
+ "svelte": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "svelte": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "CHANGELOG.md",
20
+ "!dist/**/*.test.*",
21
+ "!dist/**/*.spec.*"
22
+ ],
23
+ "scripts": {
24
+ "dev": "vite dev",
25
+ "build": "vite build",
26
+ "package": "svelte-kit sync && svelte-package",
27
+ "prepublishOnly": "npm run package",
28
+ "preview": "vite preview",
29
+ "prepare": "svelte-kit sync || echo ''",
30
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
31
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
32
+ "test": "vitest run --passWithNoTests",
33
+ "test:watch": "vitest"
34
+ },
35
+ "peerDependencies": {
36
+ "svelte": "^5.0.0"
37
+ },
38
+ "dependencies": {
39
+ "@aws-sdk/client-s3": "^3.1079.0",
40
+ "@aws-sdk/s3-request-presigner": "^3.1079.0",
41
+ "@paralleldrive/cuid2": "^2.3.1",
42
+ "sharp": "^0.34.5"
43
+ },
44
+ "devDependencies": {
45
+ "@sveltejs/adapter-auto": "^7.0.1",
46
+ "@sveltejs/kit": "^2.69.1",
47
+ "@sveltejs/package": "^2.5.8",
48
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
49
+ "@types/node": "^25.9.4",
50
+ "svelte": "^5.56.4",
51
+ "svelte-check": "^4.7.1",
52
+ "typescript": "^5.9.3",
53
+ "vite": "^7.3.6",
54
+ "vitest": "^4.1.9"
55
+ },
56
+ "keywords": [
57
+ "svelte",
58
+ "image",
59
+ "upload",
60
+ "s3",
61
+ "media",
62
+ "sharp"
63
+ ],
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "https://github.com/nomideusz/svelte-media"
67
+ }
68
+ }