includio-cms 0.0.35 → 0.0.36

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.
@@ -3,6 +3,8 @@
3
3
  import Input from '../../../components/ui/input/input.svelte';
4
4
 
5
5
  import { getRemotes } from '../../context/remotes.js';
6
+ import Sparkles from '@tabler/icons-svelte/icons/sparkles';
7
+ import { toast } from 'svelte-sonner';
6
8
 
7
9
  const remotes = getRemotes();
8
10
 
@@ -14,18 +16,55 @@
14
16
  let { alt, fileId }: Props = $props();
15
17
 
16
18
  let newAlt = $state(alt ?? '');
19
+
20
+ let input: HTMLInputElement | null = $state(null);
21
+
22
+ let generating = $state(false);
23
+
24
+ async function generateAltText() {
25
+ if (!input) return;
26
+
27
+ try {
28
+ input.disabled = true;
29
+ generating = true;
30
+
31
+ newAlt = await remotes.generateAltText({ fileId });
32
+ } catch (error) {
33
+ console.error('Error generating alt text:', error);
34
+ toast.error('Failed to generate alt text');
35
+ } finally {
36
+ input.disabled = false;
37
+ generating = false;
38
+ }
39
+ }
17
40
  </script>
18
41
 
19
- <Input
20
- bind:value={newAlt}
21
- placeholder="Wprowadź tekst alternatywny"
22
- class="w-full"
23
- aria-label="Tekst alternatywny obrazu"
24
- />
42
+ <div class="flex items-center gap-2">
43
+ <Input
44
+ bind:ref={input}
45
+ bind:value={newAlt}
46
+ placeholder="Wprowadź tekst alternatywny"
47
+ class="w-full"
48
+ aria-label="Tekst alternatywny obrazu"
49
+ />
50
+ <Button
51
+ size="icon"
52
+ onclick={generateAltText}
53
+ disabled={generating}
54
+ aria-label="Generate alt text"
55
+ >
56
+ <Sparkles class={generating ? 'animate-bounce' : ''} />
57
+ </Button>
58
+ </div>
25
59
 
26
60
  <Button
27
61
  onclick={() => {
28
- remotes.setMediaFileAlt({ fileId, alt: newAlt });
62
+ try {
63
+ remotes.setMediaFileAlt({ fileId, alt: newAlt });
64
+ toast.success('Alt text saved');
65
+ } catch {
66
+ toast.error('Failed to save alt text');
67
+ }
29
68
  }}
30
69
  class="mt-2"
31
70
  disabled={!newAlt.trim() || (alt !== null && newAlt === alt)}>Save alt</Button
@@ -132,63 +132,65 @@
132
132
 
133
133
  <Item.Root class="w-[25.375rem] shrink-0 p-0" variant="outline">
134
134
  <Item.Content class="h-full">
135
- {#if currentFile}
136
- <div class="flex items-center justify-end border-b px-6 py-3">
137
- <Button
138
- type="button"
139
- size="sm"
140
- class="mr-2.5"
141
- variant="destructive"
142
- onclick={deleteFileCommand}>Delete</Button
143
- >
144
- </div>
145
- <div class="space-y-6 p-6">
146
- <div class="mb-6 flex justify-between gap-6">
147
- <div class="aspect-square w-[40.2%] shrink-0 overflow-hidden rounded-2xl border p-1">
148
- <img
149
- src={currentFile.url}
150
- alt={currentFile.name}
151
- class="size-full rounded-lg object-contain"
152
- />
153
- </div>
154
- <div class="mr-2.5 w-[50.2%] shrink-0 space-y-4">
155
- <div class="grid grid-cols-1 gap-2">
156
- <Label>Type</Label>
157
- <Input
158
- disabled
159
- value={folders.find((folder) => folder.id === currentFile?.folderId)?.name}
135
+ {#key currentFile}
136
+ {#if currentFile}
137
+ <div class="flex items-center justify-end border-b px-6 py-3">
138
+ <Button
139
+ type="button"
140
+ size="sm"
141
+ class="mr-2.5"
142
+ variant="destructive"
143
+ onclick={deleteFileCommand}>Delete</Button
144
+ >
145
+ </div>
146
+ <div class="space-y-6 p-6">
147
+ <div class="mb-6 flex justify-between gap-6">
148
+ <div class="aspect-square w-[40.2%] shrink-0 overflow-hidden rounded-2xl border p-1">
149
+ <img
150
+ src={currentFile.url}
151
+ alt={currentFile.name}
152
+ class="size-full rounded-lg object-contain"
160
153
  />
161
154
  </div>
162
- <div class="grid grid-cols-1 gap-2">
163
- <Label>Name</Label>
164
- <Input disabled value={currentFile.name} />
155
+ <div class="mr-2.5 w-[50.2%] shrink-0 space-y-4">
156
+ <div class="grid grid-cols-1 gap-2">
157
+ <Label>Type</Label>
158
+ <Input
159
+ disabled
160
+ value={folders.find((folder) => folder.id === currentFile?.folderId)?.name}
161
+ />
162
+ </div>
163
+ <div class="grid grid-cols-1 gap-2">
164
+ <Label>Name</Label>
165
+ <Input disabled value={currentFile.name} />
166
+ </div>
165
167
  </div>
166
168
  </div>
167
- </div>
168
169
 
169
- <div class="grid grid-cols-1 gap-2">
170
- <Label>URL</Label>
171
- <Input disabled value={currentFile.url} />
172
- </div>
170
+ <div class="grid grid-cols-1 gap-2">
171
+ <Label>URL</Label>
172
+ <Input disabled value={currentFile.url} />
173
+ </div>
173
174
 
174
- <div class="grid grid-cols-1 gap-2">
175
- <Label>Alt text</Label>
176
- <AltInput alt={currentFile.alt} fileId={currentFile.id} />
175
+ <div class="grid grid-cols-1 gap-2">
176
+ <Label>Alt text</Label>
177
+ <AltInput alt={currentFile.alt} fileId={currentFile.id} />
178
+ </div>
177
179
  </div>
178
- </div>
179
- <div class="mt-auto p-6">
180
- <p class="text-muted-foregound text-xs">
181
- Created at: <span class="text-foreground"
182
- >{new Date(currentFile.createdAt).toLocaleString()}</span
183
- >
184
- </p>
185
- <p class="text-muted-foregound text-xs">
186
- ID: <span class="text-foreground">{currentFile.id}</span>
187
- </p>
188
- </div>
189
- {:else}
190
- Select a file to see details
191
- {/if}
180
+ <div class="mt-auto p-6">
181
+ <p class="text-muted-foregound text-xs">
182
+ Created at: <span class="text-foreground"
183
+ >{new Date(currentFile.createdAt).toLocaleString()}</span
184
+ >
185
+ </p>
186
+ <p class="text-muted-foregound text-xs">
187
+ ID: <span class="text-foreground">{currentFile.id}</span>
188
+ </p>
189
+ </div>
190
+ {:else}
191
+ Select a file to see details
192
+ {/if}
193
+ {/key}
192
194
  </Item.Content>
193
195
  </Item.Root>
194
196
  </div>
@@ -0,0 +1,3 @@
1
+ export declare const generateAltText: import("@sveltejs/kit").RemoteCommand<{
2
+ fileId: string;
3
+ }, Promise<string>>;
@@ -0,0 +1,8 @@
1
+ import { command } from '$app/server';
2
+ import { getCMS } from '../../core/cms.js';
3
+ import z from 'zod';
4
+ export const generateAltText = command(z.object({
5
+ fileId: z.string().uuid()
6
+ }), async ({ fileId }) => {
7
+ return getCMS().aiAdapter?.generateAltText(fileId) || '';
8
+ });
@@ -6,3 +6,4 @@ export * from './record.remote.js';
6
6
  export * from './user.remote.js';
7
7
  export * from './languages.remote.js';
8
8
  export * from './form.remote.js';
9
+ export * from './ai.remote.js';
@@ -6,3 +6,4 @@ export * from './record.remote.js';
6
6
  export * from './user.remote.js';
7
7
  export * from './languages.remote.js';
8
8
  export * from './form.remote.js';
9
+ export * from './ai.remote.js';
@@ -0,0 +1,2 @@
1
+ import type { AIAdapter, AIConfig } from '../types/adapters/ai.js';
2
+ export declare function openAIAdapter(config: AIConfig): AIAdapter;
@@ -0,0 +1,61 @@
1
+ import { getCMS } from '../core/cms.js';
2
+ import OpenAI from 'openai';
3
+ import { zodResponseFormat } from 'openai/helpers/zod.mjs';
4
+ import z from 'zod';
5
+ import sharp from 'sharp';
6
+ export function openAIAdapter(config) {
7
+ const openai = new OpenAI({
8
+ apiKey: config.apiKey
9
+ });
10
+ return {
11
+ generateAltText: async (fileId) => {
12
+ const altTextSchema = z.object({
13
+ altText: z.string().min(1)
14
+ });
15
+ const mediaFile = await getCMS().databaseAdapter.getMediaFile({
16
+ data: {
17
+ id: fileId
18
+ }
19
+ });
20
+ if (!mediaFile) {
21
+ throw new Error('Media file not found in database');
22
+ }
23
+ const file = await getCMS().filesAdapter.downloadFile(mediaFile.url.split('/').pop() || '');
24
+ if (!file) {
25
+ throw new Error('File not found');
26
+ }
27
+ // Konwertuj plik na PNG używając Sharp i przekonwertuj na base64
28
+ const fileBuffer = Buffer.from(await file.arrayBuffer());
29
+ const pngBuffer = await sharp(fileBuffer).png().toBuffer();
30
+ const imageBase64 = pngBuffer.toString('base64');
31
+ const prompt = `Generate a concise and descriptive alt text for the following image file in polish language. The alt text should accurately describe the content and context of the image, be no longer than 125 characters, and avoid using phrases like "image of" or "picture of".`;
32
+ const completion = await openai.chat.completions.parse({
33
+ // model: 'gpt-4.1-nano',
34
+ model: 'gpt-4o',
35
+ messages: [
36
+ {
37
+ role: 'user',
38
+ content: [
39
+ {
40
+ type: 'text',
41
+ text: prompt
42
+ },
43
+ {
44
+ type: 'image_url',
45
+ image_url: {
46
+ url: `data:image/png;base64,${imageBase64}`
47
+ }
48
+ }
49
+ ]
50
+ }
51
+ ],
52
+ response_format: zodResponseFormat(altTextSchema, 'response')
53
+ });
54
+ const response = completion.choices[0].message;
55
+ if (!response.parsed) {
56
+ throw new Error('AI response did not match expected format');
57
+ }
58
+ return response.parsed.altText;
59
+ }
60
+ };
61
+ }
@@ -6,10 +6,12 @@ import type { Language } from '../types/languages.js';
6
6
  import type { SingleConfigWithType } from '../types/singles.js';
7
7
  import type { PluginConfig } from '../types/plugins.js';
8
8
  import type { FormConfig } from '../types/forms.js';
9
+ import type { AIAdapter } from '../types/adapters/ai.js';
9
10
  export declare class CMS implements ICMS {
10
11
  private config;
11
12
  databaseAdapter: DatabaseAdapter;
12
13
  filesAdapter: FilesAdapter;
14
+ aiAdapter: AIAdapter | null;
13
15
  collections: Record<string, CollectionConfigWithType>;
14
16
  singles: Record<string, SingleConfigWithType>;
15
17
  forms: Record<string, FormConfig>;
package/dist/core/cms.js CHANGED
@@ -3,6 +3,7 @@ export class CMS {
3
3
  config;
4
4
  databaseAdapter;
5
5
  filesAdapter;
6
+ aiAdapter = null;
6
7
  collections;
7
8
  singles;
8
9
  forms;
@@ -12,6 +13,7 @@ export class CMS {
12
13
  this.config = config;
13
14
  this.databaseAdapter = config.db;
14
15
  this.filesAdapter = config.files;
16
+ this.aiAdapter = config.ai || null;
15
17
  // Initialize auth service
16
18
  initAuth(config.auth.adapter(config.auth.config, this.databaseAdapter), {
17
19
  cookieName: config.auth.config.sessionCookieName
@@ -1,7 +1,15 @@
1
1
  import { getCMS } from '../../../../cms.js';
2
2
  import sharp from 'sharp';
3
3
  export async function generateImageStyle(mediaFileId, style) {
4
- const file = await getCMS().filesAdapter.getFile(mediaFileId);
4
+ const mediaFile = await getCMS().databaseAdapter.getMediaFile({
5
+ data: {
6
+ id: mediaFileId
7
+ }
8
+ });
9
+ if (!mediaFile) {
10
+ throw new Error('Media file not found in database');
11
+ }
12
+ const file = await getCMS().filesAdapter.downloadFile(mediaFile.url.split('/').pop() || '');
5
13
  if (!file) {
6
14
  throw new Error('Media file not found');
7
15
  }
@@ -53,14 +53,15 @@ async function processVideo(filepath, filename) {
53
53
  }
54
54
  export function local() {
55
55
  return {
56
- getFile: async (id) => {
57
- const filepath = resolve(fullDir, id);
56
+ downloadFile: async (filename) => {
57
+ const filepath = resolve(fullDir, filename);
58
58
  try {
59
59
  const data = await readFile(filepath);
60
- const file = new File([data], id);
60
+ const file = new File([data], filename);
61
61
  return file;
62
62
  }
63
- catch {
63
+ catch (e) {
64
+ console.log(e);
64
65
  return null;
65
66
  }
66
67
  },
@@ -0,0 +1,11 @@
1
+ export interface AIConfig {
2
+ apiKey: string;
3
+ }
4
+ export type AIAdapterConfig = {
5
+ config: AIConfig;
6
+ adapter: (config: AIConfig, db: AIAdapter) => AIAdapter;
7
+ };
8
+ export interface AIAdapter {
9
+ generateAltText: GenerateAltText;
10
+ }
11
+ export type GenerateAltText = (fileId: string) => Promise<string>;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,7 +1,7 @@
1
1
  import type { UploadedMediaFile } from '../media.js';
2
2
  export interface FilesAdapter {
3
- getFile: GetFile;
3
+ downloadFile: DownloadFile;
4
4
  uploadFile: UploadFile;
5
5
  }
6
- export type GetFile = (id: string) => Promise<File | null>;
6
+ export type DownloadFile = (id: string) => Promise<File | null>;
7
7
  export type UploadFile = (file: File) => Promise<UploadedMediaFile>;
@@ -6,6 +6,7 @@ import type { Language } from './languages.js';
6
6
  import type { SingleConfig, SingleConfigWithType } from './singles.js';
7
7
  import type { PluginConfig } from './plugins.js';
8
8
  import type { FormConfig } from './forms.js';
9
+ import type { AIAdapter } from './adapters/ai.js';
9
10
  export interface CMSConfig {
10
11
  languages: Language[];
11
12
  collections?: CollectionConfig[];
@@ -15,6 +16,7 @@ export interface CMSConfig {
15
16
  files: FilesAdapter;
16
17
  auth: AuthAdapterConfig;
17
18
  plugins?: PluginConfig[];
19
+ ai?: AIAdapter;
18
20
  }
19
21
  export interface ICMS {
20
22
  collections: Record<string, CollectionConfigWithType>;
@@ -24,4 +26,5 @@ export interface ICMS {
24
26
  databaseAdapter: DatabaseAdapter;
25
27
  filesAdapter: FilesAdapter;
26
28
  plugins: PluginConfig[];
29
+ aiAdapter: AIAdapter | null;
27
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.0.35",
3
+ "version": "0.0.36",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",
@@ -92,6 +92,10 @@
92
92
  "./auth-lucia": {
93
93
  "types": "./dist/auth-lucia/index.d.ts",
94
94
  "node": "./dist/auth-lucia/index.js"
95
+ },
96
+ "./ai-openai": {
97
+ "types": "./dist/ai-openai/index.d.ts",
98
+ "node": "./dist/ai-openai/index.js"
95
99
  }
96
100
  },
97
101
  "peerDependencies": {
@@ -124,6 +128,7 @@
124
128
  "@types/node": "^22",
125
129
  "clsx": "^2.1.1",
126
130
  "drizzle-kit": "^0.30.2",
131
+ "drizzle-orm": "^0.40.0",
127
132
  "eslint": "^9.18.0",
128
133
  "eslint-config-prettier": "^10.0.1",
129
134
  "eslint-plugin-storybook": "9.0.6",
@@ -144,8 +149,7 @@
144
149
  "typescript": "^5.0.0",
145
150
  "typescript-eslint": "^8.20.0",
146
151
  "vite": "^7.0.4",
147
- "vitest": "^3.2.3",
148
- "drizzle-orm": "^0.40.0"
152
+ "vitest": "^3.2.3"
149
153
  },
150
154
  "keywords": [
151
155
  "svelte"
@@ -168,6 +172,7 @@
168
172
  "fast-glob": "^3.3.3",
169
173
  "fluent-ffmpeg": "^2.1.3",
170
174
  "mode-watcher": "^1.0.8",
175
+ "openai": "^6.7.0",
171
176
  "path-to-regexp": "^8.2.0",
172
177
  "postgres": "^3.4.5",
173
178
  "readline": "^1.3.0",