@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 +33 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/components/ImageGallery.svelte +143 -0
- package/dist/components/ImageGallery.svelte.d.ts +10 -0
- package/dist/components/ImageUpload.svelte +107 -0
- package/dist/components/ImageUpload.svelte.d.ts +11 -0
- package/dist/core/adapter.d.ts +2 -0
- package/dist/core/adapter.js +33 -0
- package/dist/core/local-adapter.d.ts +6 -0
- package/dist/core/local-adapter.js +17 -0
- package/dist/core/process.d.ts +24 -0
- package/dist/core/process.js +76 -0
- package/dist/core/types.d.ts +47 -0
- package/dist/core/types.js +3 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/package.json +68 -0
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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|