create-fluxstack 1.20.1 → 1.21.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/LLMD/resources/live-components.md +103 -57
- package/LLMD/resources/live-rooms.md +187 -88
- package/README.md +27 -25
- package/app/client/.live-stubs/LiveCounter.js +4 -4
- package/app/client/src/App.tsx +11 -12
- package/app/client/src/components/AppLayout.tsx +290 -252
- package/app/client/src/components/BackButton.tsx +16 -13
- package/app/client/src/components/DemoPage.tsx +135 -22
- package/app/client/src/index.css +21 -11
- package/app/client/src/live/AuthDemo.tsx +270 -333
- package/app/client/src/live/CounterDemo.tsx +151 -206
- package/app/client/src/live/FormDemo.tsx +140 -119
- package/app/client/src/live/PingPongDemo.tsx +180 -202
- package/app/client/src/live/RoomChatDemo.tsx +397 -374
- package/app/client/src/pages/HomePage.tsx +170 -104
- package/app/server/live/LiveCounter.ts +71 -68
- package/app/server/live/LiveSharedCounter.ts +18 -12
- package/app/server/live/auto-generated-components.ts +1 -3
- package/app/server/live/rooms/CounterRoom.ts +15 -10
- package/core/client/index.ts +0 -3
- package/core/client/state/createStore.ts +88 -88
- package/core/client/state/index.ts +5 -5
- package/core/server/live/auto-generated-components.ts +1 -3
- package/core/utils/version.ts +1 -1
- package/package.json +1 -1
- package/tsconfig.json +7 -6
- package/app/client/src/components/LiveUploadWidget.tsx +0 -200
- package/app/client/src/live/UploadDemo.tsx +0 -21
- package/app/server/live/LiveUpload.ts +0 -96
- package/core/client/hooks/useLiveUpload.ts +0 -70
|
@@ -3,44 +3,44 @@
|
|
|
3
3
|
* Core FluxStack state management utilities
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { create } from 'zustand'
|
|
7
|
-
import { persist, createJSONStorage } from 'zustand/middleware'
|
|
8
|
-
|
|
9
|
-
export interface StoreOptions<T> {
|
|
10
|
-
name?: string
|
|
11
|
-
persist?: boolean
|
|
12
|
-
storage?: 'localStorage' | 'sessionStorage'
|
|
13
|
-
version?: number
|
|
14
|
-
migrate?: (persistedState: unknown, version: number) => T
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Create a Zustand store with FluxStack conventions
|
|
19
|
-
*/
|
|
20
|
-
export function createFluxStore<T>(
|
|
21
|
-
storeFactory: (set: (partial: Partial<T> | ((state: T) => Partial<T>)) => void, get: () => T) => T,
|
|
22
|
-
options: StoreOptions<T> = {}
|
|
23
|
-
) {
|
|
24
|
-
const { name, persist: shouldPersist = false, storage = 'localStorage', version = 1, migrate } = options
|
|
25
|
-
|
|
26
|
-
if (shouldPersist && name) {
|
|
27
|
-
return create<T>()(
|
|
28
|
-
persist(
|
|
29
|
-
storeFactory,
|
|
30
|
-
{
|
|
31
|
-
name,
|
|
32
|
-
storage: createJSONStorage(() =>
|
|
33
|
-
storage === 'localStorage' ? localStorage : sessionStorage
|
|
34
|
-
),
|
|
35
|
-
version,
|
|
36
|
-
migrate: migrate as ((persistedState: unknown, version: number) => T) | undefined,
|
|
37
|
-
onRehydrateStorage: () => (state) => {
|
|
38
|
-
console.log('FluxStack: Store rehydrated', name, state)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
)
|
|
42
|
-
)
|
|
43
|
-
}
|
|
6
|
+
import { create } from 'zustand'
|
|
7
|
+
import { persist, createJSONStorage } from 'zustand/middleware'
|
|
8
|
+
|
|
9
|
+
export interface StoreOptions<T> {
|
|
10
|
+
name?: string
|
|
11
|
+
persist?: boolean
|
|
12
|
+
storage?: 'localStorage' | 'sessionStorage'
|
|
13
|
+
version?: number
|
|
14
|
+
migrate?: (persistedState: unknown, version: number) => T
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a Zustand store with FluxStack conventions
|
|
19
|
+
*/
|
|
20
|
+
export function createFluxStore<T>(
|
|
21
|
+
storeFactory: (set: (partial: Partial<T> | ((state: T) => Partial<T>)) => void, get: () => T) => T,
|
|
22
|
+
options: StoreOptions<T> = {}
|
|
23
|
+
) {
|
|
24
|
+
const { name, persist: shouldPersist = false, storage = 'localStorage', version = 1, migrate } = options
|
|
25
|
+
|
|
26
|
+
if (shouldPersist && name) {
|
|
27
|
+
return create<T>()(
|
|
28
|
+
persist(
|
|
29
|
+
storeFactory,
|
|
30
|
+
{
|
|
31
|
+
name,
|
|
32
|
+
storage: createJSONStorage(() =>
|
|
33
|
+
storage === 'localStorage' ? localStorage : sessionStorage
|
|
34
|
+
),
|
|
35
|
+
version,
|
|
36
|
+
migrate: migrate as ((persistedState: unknown, version: number) => T) | undefined,
|
|
37
|
+
onRehydrateStorage: () => (state) => {
|
|
38
|
+
console.log('FluxStack: Store rehydrated', name, state)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
44
|
|
|
45
45
|
return create<T>()(storeFactory)
|
|
46
46
|
}
|
|
@@ -65,27 +65,27 @@ export interface BaseUserStore {
|
|
|
65
65
|
logout: () => void
|
|
66
66
|
updateProfile: (data: Partial<BaseUser>) => Promise<void>
|
|
67
67
|
clearError: () => void
|
|
68
|
-
setLoading: (loading: boolean) => void
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Create user store with FluxStack conventions
|
|
73
|
-
*/
|
|
74
|
-
export function createUserStore(options: StoreOptions<BaseUserStore> = {}) {
|
|
75
|
-
return createFluxStore<BaseUserStore>(
|
|
76
|
-
(set, get) => ({
|
|
77
|
-
currentUser: null,
|
|
78
|
-
isAuthenticated: false,
|
|
68
|
+
setLoading: (loading: boolean) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create user store with FluxStack conventions
|
|
73
|
+
*/
|
|
74
|
+
export function createUserStore(options: StoreOptions<BaseUserStore> = {}) {
|
|
75
|
+
return createFluxStore<BaseUserStore>(
|
|
76
|
+
(set, get) => ({
|
|
77
|
+
currentUser: null,
|
|
78
|
+
isAuthenticated: false,
|
|
79
79
|
isLoading: false,
|
|
80
80
|
error: null,
|
|
81
81
|
|
|
82
|
-
login: async (credentials) => {
|
|
83
|
-
set({ isLoading: true, error: null })
|
|
84
|
-
try {
|
|
85
|
-
const response = await fetch('/api/auth/login', {
|
|
86
|
-
method: 'POST',
|
|
87
|
-
headers: { 'Content-Type': 'application/json' },
|
|
88
|
-
body: JSON.stringify(credentials)
|
|
82
|
+
login: async (credentials) => {
|
|
83
|
+
set({ isLoading: true, error: null })
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch('/api/auth/login', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify(credentials)
|
|
89
89
|
})
|
|
90
90
|
|
|
91
91
|
if (!response.ok) {
|
|
@@ -108,13 +108,13 @@ export function createUserStore(options: StoreOptions<BaseUserStore> = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
},
|
|
110
110
|
|
|
111
|
-
register: async (data) => {
|
|
112
|
-
set({ isLoading: true, error: null })
|
|
113
|
-
try {
|
|
114
|
-
const response = await fetch('/api/auth/register', {
|
|
115
|
-
method: 'POST',
|
|
116
|
-
headers: { 'Content-Type': 'application/json' },
|
|
117
|
-
body: JSON.stringify(data)
|
|
111
|
+
register: async (data) => {
|
|
112
|
+
set({ isLoading: true, error: null })
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch('/api/auth/register', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify(data)
|
|
118
118
|
})
|
|
119
119
|
|
|
120
120
|
if (!response.ok) {
|
|
@@ -135,14 +135,14 @@ export function createUserStore(options: StoreOptions<BaseUserStore> = {}) {
|
|
|
135
135
|
})
|
|
136
136
|
throw error
|
|
137
137
|
}
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
logout: () => {
|
|
141
|
-
// Call logout API
|
|
142
|
-
fetch('/api/auth/logout', { method: 'POST' }).catch(console.error)
|
|
143
|
-
|
|
144
|
-
set({
|
|
145
|
-
currentUser: null,
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
logout: () => {
|
|
141
|
+
// Call logout API
|
|
142
|
+
fetch('/api/auth/logout', { method: 'POST' }).catch(console.error)
|
|
143
|
+
|
|
144
|
+
set({
|
|
145
|
+
currentUser: null,
|
|
146
146
|
isAuthenticated: false,
|
|
147
147
|
error: null
|
|
148
148
|
})
|
|
@@ -151,15 +151,15 @@ export function createUserStore(options: StoreOptions<BaseUserStore> = {}) {
|
|
|
151
151
|
updateProfile: async (data) => {
|
|
152
152
|
const { currentUser } = get()
|
|
153
153
|
if (!currentUser) {
|
|
154
|
-
throw new Error('No user logged in')
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
set({ isLoading: true, error: null })
|
|
158
|
-
try {
|
|
159
|
-
const response = await fetch('/api/user/profile', {
|
|
160
|
-
method: 'PUT',
|
|
161
|
-
headers: { 'Content-Type': 'application/json' },
|
|
162
|
-
body: JSON.stringify(data)
|
|
154
|
+
throw new Error('No user logged in')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
set({ isLoading: true, error: null })
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch('/api/user/profile', {
|
|
160
|
+
method: 'PUT',
|
|
161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify(data)
|
|
163
163
|
})
|
|
164
164
|
|
|
165
165
|
if (!response.ok) {
|
|
@@ -183,11 +183,11 @@ export function createUserStore(options: StoreOptions<BaseUserStore> = {}) {
|
|
|
183
183
|
|
|
184
184
|
clearError: () => set({ error: null }),
|
|
185
185
|
setLoading: (loading) => set({ isLoading: loading })
|
|
186
|
-
}),
|
|
187
|
-
{
|
|
188
|
-
name: 'user-store',
|
|
189
|
-
persist: true,
|
|
190
|
-
...options
|
|
191
|
-
}
|
|
192
|
-
)
|
|
193
|
-
}
|
|
186
|
+
}),
|
|
187
|
+
{
|
|
188
|
+
name: 'user-store',
|
|
189
|
+
persist: true,
|
|
190
|
+
...options
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-generated Live Components Registration
|
|
2
2
|
// Generated by @fluxstack/live — DO NOT EDIT MANUALLY
|
|
3
|
-
// Generated at: 2026-
|
|
3
|
+
// Generated at: 2026-04-15T23:06:28.948Z
|
|
4
4
|
|
|
5
5
|
import { LiveAdminPanel } from "@app/server/live/LiveAdminPanel"
|
|
6
6
|
import { LiveCounter } from "@app/server/live/LiveCounter"
|
|
@@ -10,7 +10,6 @@ import { LivePingPong } from "@app/server/live/LivePingPong"
|
|
|
10
10
|
import { LiveProtectedChat } from "@app/server/live/LiveProtectedChat"
|
|
11
11
|
import { LiveRoomChat } from "@app/server/live/LiveRoomChat"
|
|
12
12
|
import { LiveSharedCounter } from "@app/server/live/LiveSharedCounter"
|
|
13
|
-
import { LiveUpload } from "@app/server/live/LiveUpload"
|
|
14
13
|
|
|
15
14
|
// Component classes array for LiveServer({ components }) option
|
|
16
15
|
export const liveComponentClasses = [
|
|
@@ -22,5 +21,4 @@ export const liveComponentClasses = [
|
|
|
22
21
|
LiveProtectedChat,
|
|
23
22
|
LiveRoomChat,
|
|
24
23
|
LiveSharedCounter,
|
|
25
|
-
LiveUpload,
|
|
26
24
|
]
|
package/core/utils/version.ts
CHANGED
package/package.json
CHANGED
package/tsconfig.json
CHANGED
|
@@ -44,9 +44,10 @@
|
|
|
44
44
|
},
|
|
45
45
|
"exclude": [
|
|
46
46
|
"examples/**/*",
|
|
47
|
-
"**/*.test.ts",
|
|
48
|
-
"**/__tests__/**/*",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
"**/*.test.ts",
|
|
48
|
+
"**/__tests__/**/*",
|
|
49
|
+
"dist/**/*",
|
|
50
|
+
"run-env-tests.ts",
|
|
51
|
+
"plugins/**/scripts/**/*"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
-
import { useLiveChunkedUpload } from '@/core/client'
|
|
3
|
-
import type { LiveChunkedUploadOptions } from '@/core/client'
|
|
4
|
-
import type { FileUploadCompleteResponse } from '@core/types/types'
|
|
5
|
-
import { LiveUpload } from '@server/live/LiveUpload'
|
|
6
|
-
|
|
7
|
-
// Derive the state type from the actual LiveUpload component to avoid duplication
|
|
8
|
-
type LiveUploadState = typeof LiveUpload.defaultState
|
|
9
|
-
|
|
10
|
-
// Minimal interface for any Live.use() proxy compatible with LiveUpload
|
|
11
|
-
interface LiveUploadProxy {
|
|
12
|
-
$componentId: string | null
|
|
13
|
-
$connected: boolean
|
|
14
|
-
$state: LiveUploadState
|
|
15
|
-
$error?: string | null
|
|
16
|
-
startUpload: (payload: { fileName: string; fileSize: number; fileType: string }) => Promise<any>
|
|
17
|
-
updateProgress: (payload: { progress: number; bytesUploaded: number; totalBytes: number }) => Promise<any>
|
|
18
|
-
completeUpload: (payload: { fileUrl: string }) => Promise<any>
|
|
19
|
-
failUpload: (payload: { error: string }) => Promise<any>
|
|
20
|
-
reset: () => Promise<any>
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface LiveUploadWidgetProps {
|
|
24
|
-
live: LiveUploadProxy
|
|
25
|
-
title?: string
|
|
26
|
-
description?: string
|
|
27
|
-
allowPreview?: boolean
|
|
28
|
-
options?: LiveChunkedUploadOptions
|
|
29
|
-
onComplete?: (response: FileUploadCompleteResponse) => void
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function LiveUploadWidget({
|
|
33
|
-
live,
|
|
34
|
-
title = 'Upload em Chunks',
|
|
35
|
-
description = 'Envio via WebSocket com Live Components e reatividade server-side.',
|
|
36
|
-
allowPreview = true,
|
|
37
|
-
options,
|
|
38
|
-
onComplete
|
|
39
|
-
}: LiveUploadWidgetProps) {
|
|
40
|
-
// live is expected to be a LiveUpload-compatible component
|
|
41
|
-
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
42
|
-
const [localError, setLocalError] = useState<string | null>(null)
|
|
43
|
-
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
|
44
|
-
|
|
45
|
-
const mergedOptions = useMemo<LiveChunkedUploadOptions>(() => {
|
|
46
|
-
return {
|
|
47
|
-
allowedTypes: [],
|
|
48
|
-
maxFileSize: 500 * 1024 * 1024,
|
|
49
|
-
adaptiveChunking: true,
|
|
50
|
-
fileUrlResolver: (fileUrl) => fileUrl.startsWith('/uploads/') ? `/api${fileUrl}` : fileUrl,
|
|
51
|
-
onComplete,
|
|
52
|
-
...options
|
|
53
|
-
}
|
|
54
|
-
}, [options, onComplete])
|
|
55
|
-
|
|
56
|
-
const {
|
|
57
|
-
uploading,
|
|
58
|
-
bytesUploaded,
|
|
59
|
-
totalBytes,
|
|
60
|
-
uploadFile,
|
|
61
|
-
cancelUpload,
|
|
62
|
-
reset
|
|
63
|
-
} = useLiveChunkedUpload(live, mergedOptions)
|
|
64
|
-
|
|
65
|
-
const canUpload = live.$connected && !!live.$componentId && !uploading
|
|
66
|
-
|
|
67
|
-
const handleSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
68
|
-
const file = e.target.files?.[0] ?? null
|
|
69
|
-
setSelectedFile(file)
|
|
70
|
-
setLocalError(null)
|
|
71
|
-
|
|
72
|
-
if (previewUrl) {
|
|
73
|
-
URL.revokeObjectURL(previewUrl)
|
|
74
|
-
setPreviewUrl(null)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (allowPreview && file && file.type.startsWith('image/')) {
|
|
78
|
-
setPreviewUrl(URL.createObjectURL(file))
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
return () => {
|
|
84
|
-
if (previewUrl) {
|
|
85
|
-
URL.revokeObjectURL(previewUrl)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}, [previewUrl])
|
|
89
|
-
|
|
90
|
-
const handleStartUpload = async () => {
|
|
91
|
-
if (!selectedFile) {
|
|
92
|
-
setLocalError('Selecione um arquivo primeiro.')
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (!live.$connected || !live.$componentId) {
|
|
97
|
-
setLocalError('WebSocket ainda nao conectou. Tente novamente em alguns segundos.')
|
|
98
|
-
return
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
setLocalError(null)
|
|
102
|
-
await uploadFile(selectedFile)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const handleReset = async () => {
|
|
106
|
-
setSelectedFile(null)
|
|
107
|
-
setLocalError(null)
|
|
108
|
-
if (previewUrl) {
|
|
109
|
-
URL.revokeObjectURL(previewUrl)
|
|
110
|
-
setPreviewUrl(null)
|
|
111
|
-
}
|
|
112
|
-
await reset()
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const resolvedUrl = live.$state.fileUrl
|
|
116
|
-
|
|
117
|
-
return (
|
|
118
|
-
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-8 max-w-xl w-full mx-auto">
|
|
119
|
-
<h2 className="text-2xl font-bold text-white mb-2 text-center">
|
|
120
|
-
{title}
|
|
121
|
-
</h2>
|
|
122
|
-
|
|
123
|
-
<p className="text-gray-400 text-sm text-center mb-6">
|
|
124
|
-
{description}
|
|
125
|
-
</p>
|
|
126
|
-
|
|
127
|
-
<div className="space-y-4">
|
|
128
|
-
<input
|
|
129
|
-
type="file"
|
|
130
|
-
onChange={handleSelectFile}
|
|
131
|
-
className="w-full text-sm text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-white/10 file:text-white hover:file:bg-white/20"
|
|
132
|
-
disabled={!live.$connected || uploading}
|
|
133
|
-
/>
|
|
134
|
-
|
|
135
|
-
<div className="flex gap-3">
|
|
136
|
-
<button
|
|
137
|
-
onClick={handleStartUpload}
|
|
138
|
-
disabled={!canUpload || !selectedFile}
|
|
139
|
-
className="flex-1 px-4 py-2 rounded-lg bg-emerald-500/20 border border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/30 transition-all disabled:opacity-50"
|
|
140
|
-
>
|
|
141
|
-
Iniciar Upload
|
|
142
|
-
</button>
|
|
143
|
-
<button
|
|
144
|
-
onClick={cancelUpload}
|
|
145
|
-
disabled={!uploading}
|
|
146
|
-
className="px-4 py-2 rounded-lg bg-red-500/20 border border-red-500/30 text-red-300 hover:bg-red-500/30 transition-all disabled:opacity-50"
|
|
147
|
-
>
|
|
148
|
-
Cancelar
|
|
149
|
-
</button>
|
|
150
|
-
<button
|
|
151
|
-
onClick={handleReset}
|
|
152
|
-
className="px-4 py-2 rounded-lg bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all"
|
|
153
|
-
>
|
|
154
|
-
Reset
|
|
155
|
-
</button>
|
|
156
|
-
</div>
|
|
157
|
-
|
|
158
|
-
{(localError || live.$state.error || live.$error) && (
|
|
159
|
-
<div className="text-sm text-red-300 bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2">
|
|
160
|
-
{localError || live.$state.error || live.$error}
|
|
161
|
-
</div>
|
|
162
|
-
)}
|
|
163
|
-
|
|
164
|
-
<div className="bg-black/40 border border-white/10 rounded-xl p-4">
|
|
165
|
-
<div className="flex justify-between text-xs text-gray-400 mb-2">
|
|
166
|
-
<span>Status: {live.$state.status}</span>
|
|
167
|
-
<span>{Math.round(live.$state.progress)}%</span>
|
|
168
|
-
</div>
|
|
169
|
-
<div className="w-full h-3 bg-white/10 rounded-full overflow-hidden">
|
|
170
|
-
<div
|
|
171
|
-
className="h-full bg-theme-gradient transition-all"
|
|
172
|
-
style={{ width: `${live.$state.progress}%` }}
|
|
173
|
-
/>
|
|
174
|
-
</div>
|
|
175
|
-
<div className="flex justify-between text-xs text-gray-500 mt-2">
|
|
176
|
-
<span>{live.$state.fileName || 'Nenhum arquivo selecionado'}</span>
|
|
177
|
-
<span>{bytesUploaded > 0 ? `${Math.round(bytesUploaded / 1024)} KB` : ''}{totalBytes > 0 ? ` / ${Math.round(totalBytes / 1024)} KB` : ''}</span>
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
{previewUrl && (
|
|
182
|
-
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
|
|
183
|
-
<div className="text-xs text-gray-400 mb-2">Preview</div>
|
|
184
|
-
<img
|
|
185
|
-
src={previewUrl}
|
|
186
|
-
alt="Preview"
|
|
187
|
-
className="max-h-48 w-full object-contain rounded-lg border border-white/10"
|
|
188
|
-
/>
|
|
189
|
-
</div>
|
|
190
|
-
)}
|
|
191
|
-
|
|
192
|
-
{resolvedUrl && live.$state.status === 'complete' && (
|
|
193
|
-
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 text-sm text-emerald-200">
|
|
194
|
-
Upload concluido: <a className="underline" href={resolvedUrl} target="_blank" rel="noopener noreferrer">abrir arquivo</a>
|
|
195
|
-
</div>
|
|
196
|
-
)}
|
|
197
|
-
</div>
|
|
198
|
-
</div>
|
|
199
|
-
)
|
|
200
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { LiveUploadWidget } from '../components/LiveUploadWidget'
|
|
3
|
-
import { useLiveUpload } from '@/core/client'
|
|
4
|
-
|
|
5
|
-
export function UploadDemo() {
|
|
6
|
-
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
|
7
|
-
const { live } = useLiveUpload({
|
|
8
|
-
onComplete: (response) => setLastUrl(response.fileUrl || null)
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
return (
|
|
12
|
-
<div className="flex flex-col gap-4 max-w-xl w-full mx-auto">
|
|
13
|
-
<LiveUploadWidget live={live} />
|
|
14
|
-
{lastUrl && (
|
|
15
|
-
<div className="text-xs text-gray-500 text-center">
|
|
16
|
-
Ultimo arquivo: {lastUrl}
|
|
17
|
-
</div>
|
|
18
|
-
)}
|
|
19
|
-
</div>
|
|
20
|
-
)
|
|
21
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// LiveUpload - Estado de upload chunked + sincronização UI
|
|
2
|
-
|
|
3
|
-
import { LiveComponent } from '@core/types/types'
|
|
4
|
-
|
|
5
|
-
// Componente Cliente (Ctrl+Click para navegar)
|
|
6
|
-
import type { UploadDemo as _Client } from '@client/src/live/UploadDemo'
|
|
7
|
-
|
|
8
|
-
export class LiveUpload extends LiveComponent<typeof LiveUpload.defaultState> {
|
|
9
|
-
static componentName = 'LiveUpload'
|
|
10
|
-
static publicActions = ['startUpload', 'updateProgress', 'completeUpload', 'failUpload', 'reset'] as const
|
|
11
|
-
static defaultState = {
|
|
12
|
-
status: 'idle' as 'idle' | 'uploading' | 'complete' | 'error',
|
|
13
|
-
progress: 0,
|
|
14
|
-
fileName: '',
|
|
15
|
-
fileSize: 0,
|
|
16
|
-
fileType: '',
|
|
17
|
-
fileUrl: '',
|
|
18
|
-
bytesUploaded: 0,
|
|
19
|
-
totalBytes: 0,
|
|
20
|
-
error: null as string | null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) {
|
|
24
|
-
const fileName = payload.fileName
|
|
25
|
-
|
|
26
|
-
// Validate filename length
|
|
27
|
-
if (!fileName || fileName.length > 255) {
|
|
28
|
-
throw new Error('Invalid file name: must be 1-255 characters')
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Block path traversal, null bytes, and control characters
|
|
32
|
-
if (/[\x00-\x1f]/.test(fileName) || fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
|
|
33
|
-
throw new Error('Invalid file name: contains forbidden characters')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Block Windows reserved names
|
|
37
|
-
const baseName = fileName.split('.')[0].toUpperCase()
|
|
38
|
-
const reserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1', 'LPT2', 'LPT3']
|
|
39
|
-
if (reserved.includes(baseName)) {
|
|
40
|
-
throw new Error('Invalid file name: reserved name')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// All file types allowed - no extension blocking
|
|
44
|
-
// Security note: Configure allowed extensions per your application needs
|
|
45
|
-
|
|
46
|
-
this.setState({
|
|
47
|
-
status: 'uploading',
|
|
48
|
-
progress: 0,
|
|
49
|
-
fileName: payload.fileName,
|
|
50
|
-
fileSize: payload.fileSize,
|
|
51
|
-
fileType: payload.fileType,
|
|
52
|
-
fileUrl: '',
|
|
53
|
-
bytesUploaded: 0,
|
|
54
|
-
totalBytes: payload.fileSize,
|
|
55
|
-
error: null
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
return { success: true }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async updateProgress(payload: { progress: number; bytesUploaded: number; totalBytes: number }) {
|
|
62
|
-
const progress = Math.max(0, Math.min(100, payload.progress))
|
|
63
|
-
this.setState({
|
|
64
|
-
progress,
|
|
65
|
-
bytesUploaded: payload.bytesUploaded,
|
|
66
|
-
totalBytes: payload.totalBytes
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
return { success: true, progress }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async completeUpload(payload: { fileUrl: string }) {
|
|
73
|
-
this.setState({
|
|
74
|
-
status: 'complete',
|
|
75
|
-
progress: 100,
|
|
76
|
-
fileUrl: payload.fileUrl,
|
|
77
|
-
error: null
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
return { success: true }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async failUpload(payload: { error: string }) {
|
|
84
|
-
this.setState({
|
|
85
|
-
status: 'error',
|
|
86
|
-
error: payload.error || 'Upload failed'
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
return { success: true }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async reset() {
|
|
93
|
-
this.setState({ ...LiveUpload.defaultState })
|
|
94
|
-
return { success: true }
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { useMemo } from 'react'
|
|
2
|
-
import { Live, useLiveChunkedUpload } from '@fluxstack/live-react'
|
|
3
|
-
import type { LiveChunkedUploadOptions } from '@fluxstack/live-react'
|
|
4
|
-
import type { FileUploadCompleteResponse } from '@fluxstack/live'
|
|
5
|
-
import { LiveUpload } from '@server/live/LiveUpload'
|
|
6
|
-
|
|
7
|
-
export interface UseLiveUploadOptions {
|
|
8
|
-
live?: {
|
|
9
|
-
room?: string
|
|
10
|
-
userId?: string
|
|
11
|
-
autoMount?: boolean
|
|
12
|
-
debug?: boolean
|
|
13
|
-
}
|
|
14
|
-
upload?: LiveChunkedUploadOptions
|
|
15
|
-
onProgress?: (progress: number, bytesUploaded: number, totalBytes: number) => void
|
|
16
|
-
onComplete?: (response: FileUploadCompleteResponse) => void
|
|
17
|
-
onError?: (error: string) => void
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function useLiveUpload(options: UseLiveUploadOptions = {}) {
|
|
21
|
-
const { live: liveOptions, upload: uploadOptions, onProgress, onComplete, onError } = options
|
|
22
|
-
|
|
23
|
-
const live = Live.use(LiveUpload, {
|
|
24
|
-
initialState: LiveUpload.defaultState,
|
|
25
|
-
...liveOptions
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
const mergedUploadOptions = useMemo<LiveChunkedUploadOptions>(() => {
|
|
29
|
-
return {
|
|
30
|
-
allowedTypes: [],
|
|
31
|
-
maxFileSize: 500 * 1024 * 1024,
|
|
32
|
-
adaptiveChunking: true,
|
|
33
|
-
fileUrlResolver: (fileUrl) => fileUrl.startsWith('/uploads/') ? `/api${fileUrl}` : fileUrl,
|
|
34
|
-
onProgress,
|
|
35
|
-
onComplete,
|
|
36
|
-
onError,
|
|
37
|
-
...uploadOptions
|
|
38
|
-
}
|
|
39
|
-
}, [onProgress, onComplete, onError, uploadOptions])
|
|
40
|
-
|
|
41
|
-
const upload = useLiveChunkedUpload(live, mergedUploadOptions)
|
|
42
|
-
|
|
43
|
-
const startUpload = useMemo(() => {
|
|
44
|
-
return async (file: File) => {
|
|
45
|
-
if (!live.$connected || !live.$componentId) {
|
|
46
|
-
const msg = 'WebSocket nao conectado. Tente novamente.'
|
|
47
|
-
onError?.(msg)
|
|
48
|
-
await live.failUpload({ error: msg })
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
await upload.uploadFile(file)
|
|
52
|
-
}
|
|
53
|
-
}, [live, upload, onError])
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
live,
|
|
57
|
-
state: live.$state,
|
|
58
|
-
status: live.$state.status,
|
|
59
|
-
connected: live.$connected,
|
|
60
|
-
componentId: live.$componentId,
|
|
61
|
-
uploading: upload.uploading,
|
|
62
|
-
progress: upload.progress,
|
|
63
|
-
bytesUploaded: upload.bytesUploaded,
|
|
64
|
-
totalBytes: upload.totalBytes,
|
|
65
|
-
error: live.$state.error,
|
|
66
|
-
startUpload,
|
|
67
|
-
cancelUpload: upload.cancelUpload,
|
|
68
|
-
reset: upload.reset
|
|
69
|
-
}
|
|
70
|
-
}
|