Working s3 compatible file uplaods!
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// Do not edit this file manually
|
||||
|
||||
|
||||
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types";
|
||||
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types";
|
||||
export type * from "./ash_types";
|
||||
|
||||
// Helper Functions
|
||||
@@ -201,8 +201,110 @@ export async function executeValidationRpcRequest<T>(
|
||||
|
||||
|
||||
|
||||
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];
|
||||
|
||||
|
||||
export type InferReadMediaResult<
|
||||
Fields extends ReadMediaFields | undefined,
|
||||
Page extends ReadMediaConfig["page"] = undefined
|
||||
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<mediaResourceSchema, Fields>>, {
|
||||
results: Array<InferResult<mediaResourceSchema, Fields>>;
|
||||
hasMore: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
count?: number | null;
|
||||
type: "offset";
|
||||
}, {
|
||||
results: Array<InferResult<mediaResourceSchema, Fields>>;
|
||||
hasMore: boolean;
|
||||
limit: number;
|
||||
after: string | null;
|
||||
before: string | null;
|
||||
previousPage: string;
|
||||
nextPage: string;
|
||||
count?: number | null;
|
||||
type: "keyset";
|
||||
}>;
|
||||
|
||||
export type ReadMediaConfig = {
|
||||
tenant?: string;
|
||||
fields: ReadMediaFields;
|
||||
filter?: mediaFilterInput;
|
||||
sort?: SortString<mediaSortField> | SortString<mediaSortField>[];
|
||||
page?: (
|
||||
{
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
count?: boolean;
|
||||
} | {
|
||||
limit?: number;
|
||||
after?: string;
|
||||
before?: string;
|
||||
}
|
||||
);
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
export type ReadMediaResult<Fields extends ReadMediaFields, Page extends ReadMediaConfig["page"] = undefined> = | { success: true; data: InferReadMediaResult<Fields, Page>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Read Media records
|
||||
*
|
||||
* @ashActionType :read
|
||||
*/
|
||||
export async function readMedia<Fields extends ReadMediaFields, Config extends ReadMediaConfig = ReadMediaConfig>(
|
||||
config: Config & { fields: Fields }
|
||||
): Promise<ReadMediaResult<Fields, Config["page"]>> {
|
||||
const payload = {
|
||||
action: "read_media",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
...(config.fields !== undefined && { fields: config.fields }),
|
||||
...(config.filter && { filter: config.filter }),
|
||||
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }),
|
||||
...(config.page && { page: config.page })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<ReadMediaResult<Fields, Config["page"]>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Read Media records
|
||||
*
|
||||
* @ashActionType :read
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateReadMedia(
|
||||
config: {
|
||||
tenant?: string;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "read_media",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant })
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type CreateTweetInput = {
|
||||
content: string;
|
||||
mediaId?: UUID;
|
||||
};
|
||||
|
||||
export type CreateTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
export type UUID = string;
|
||||
|
||||
// media Schema
|
||||
export type mediaResourceSchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "s3Key" | "userId" | "tweetId";
|
||||
id: UUID;
|
||||
s3Key: string;
|
||||
userId: UUID;
|
||||
tweetId: UUID | null;
|
||||
tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
|
||||
};
|
||||
|
||||
|
||||
|
||||
export type mediaAttributesOnlySchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "s3Key" | "userId" | "tweetId";
|
||||
id: UUID;
|
||||
s3Key: string;
|
||||
userId: UUID;
|
||||
tweetId: UUID | null;
|
||||
};
|
||||
|
||||
|
||||
// tweets Schema
|
||||
export type tweetsResourceSchema = {
|
||||
__type: "Resource";
|
||||
@@ -14,6 +37,7 @@ export type tweetsResourceSchema = {
|
||||
likes: number;
|
||||
userId: UUID;
|
||||
state: "posted" | "drafted";
|
||||
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +53,40 @@ export type tweetsAttributesOnlySchema = {
|
||||
};
|
||||
|
||||
|
||||
export type mediaFilterInput = {
|
||||
and?: Array<mediaFilterInput>;
|
||||
or?: Array<mediaFilterInput>;
|
||||
not?: Array<mediaFilterInput>;
|
||||
|
||||
id?: {
|
||||
eq?: UUID;
|
||||
notEq?: UUID;
|
||||
in?: Array<UUID>;
|
||||
};
|
||||
|
||||
s3Key?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
};
|
||||
|
||||
userId?: {
|
||||
eq?: UUID;
|
||||
notEq?: UUID;
|
||||
in?: Array<UUID>;
|
||||
};
|
||||
|
||||
tweetId?: {
|
||||
eq?: UUID;
|
||||
notEq?: UUID;
|
||||
in?: Array<UUID>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
|
||||
tweet?: tweetsFilterInput;
|
||||
|
||||
};
|
||||
export type tweetsFilterInput = {
|
||||
and?: Array<tweetsFilterInput>;
|
||||
or?: Array<tweetsFilterInput>;
|
||||
@@ -69,14 +127,21 @@ export type tweetsFilterInput = {
|
||||
};
|
||||
|
||||
|
||||
media?: mediaFilterInput;
|
||||
|
||||
};
|
||||
|
||||
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "s3Key"] as const;
|
||||
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
||||
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
||||
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "media"] as const;
|
||||
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
||||
|
||||
|
||||
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
||||
export type mediaSortField = (typeof mediaSortFields)[number];
|
||||
|
||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "state"] as const;
|
||||
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
updateTweet,
|
||||
buildCSRFHeaders,
|
||||
} from "./ash_rpc";
|
||||
import { uploadFile } from "./upload";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 10_000 } },
|
||||
@@ -21,7 +22,8 @@ const queryClient = new QueryClient({
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tweet = { id: string; content: string; userId: string; state: string };
|
||||
type MediaItem = { id: string; s3Key: string };
|
||||
type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] };
|
||||
|
||||
// ── Auth context ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -33,6 +35,11 @@ function timeAgo(): string {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
function getAssetHost(): string {
|
||||
const appEl = document.getElementById("app");
|
||||
return appEl?.dataset.assetHost ?? "http://localhost:3901";
|
||||
}
|
||||
|
||||
// ── Components ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Spinner() {
|
||||
@@ -67,14 +74,19 @@ function CharCount({ current, max }: { current: number; max: number }) {
|
||||
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [mediaId, setMediaId] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const qc = useQueryClient();
|
||||
const MAX = 280;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const res = await createTweet({
|
||||
input: { content },
|
||||
input: { content, mediaId: mediaId ?? undefined },
|
||||
fields: ["id", "content", "userId", "state"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
@@ -85,11 +97,40 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||
setText("");
|
||||
setError(null);
|
||||
setMediaId(null);
|
||||
setPendingFile(null);
|
||||
setUploadError(null);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// Reset the input so the same file can be re-selected after removal
|
||||
e.target.value = "";
|
||||
setPendingFile(file);
|
||||
setMediaId(null);
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
|
||||
const result = await uploadFile(file, csrfToken);
|
||||
setUploading(false);
|
||||
if ("error" in result) {
|
||||
setUploadError(result.error);
|
||||
setPendingFile(null);
|
||||
} else {
|
||||
setMediaId(result.mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment() {
|
||||
setPendingFile(null);
|
||||
setMediaId(null);
|
||||
setUploadError(null);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -134,13 +175,51 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||
/>
|
||||
{error && <p className="mx-compose-error">{error}</p>}
|
||||
<div className="mx-compose-footer">
|
||||
<span className="mx-compose-hint">⌘↵ to post</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="mx-action-btn"
|
||||
title="Attach image or video"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || mutation.isPending}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/mp4,video/quicktime"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{uploading && (
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--mx-muted)" }}>Uploading…</span>
|
||||
)}
|
||||
{pendingFile && !uploading && (
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--mx-muted)", display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||
{pendingFile.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeAttachment}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", padding: "0 2px", color: "inherit" }}
|
||||
title="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{uploadError && (
|
||||
<span style={{ fontSize: "0.75rem", color: "#ef4444" }}>{uploadError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-compose-actions">
|
||||
<CharCount current={text.length} max={MAX} />
|
||||
<button
|
||||
className="mx-btn-post"
|
||||
onClick={submit}
|
||||
disabled={!text.trim() || mutation.isPending}
|
||||
disabled={!text.trim() || mutation.isPending || uploading}
|
||||
>
|
||||
{mutation.isPending ? "Posting…" : "Post"}
|
||||
</button>
|
||||
@@ -151,6 +230,31 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TweetMedia({ media }: { media: MediaItem[] }) {
|
||||
const assetHost = getAssetHost();
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{media.map((m) =>
|
||||
/\.(mp4|mov)$/i.test(m.s3Key) ? (
|
||||
<video
|
||||
key={m.id}
|
||||
src={`${assetHost}/${m.s3Key}`}
|
||||
controls
|
||||
style={{ maxWidth: "100%", borderRadius: "0.5rem" }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={m.id}
|
||||
src={`${assetHost}/${m.s3Key}`}
|
||||
alt=""
|
||||
style={{ maxWidth: "100%", borderRadius: "0.5rem", display: "block" }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||
@@ -283,6 +387,10 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
<p className="mx-tweet-text">{tweet.content}</p>
|
||||
)}
|
||||
|
||||
{tweet.media && tweet.media.length > 0 && (
|
||||
<TweetMedia media={tweet.media} />
|
||||
)}
|
||||
|
||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
</article>
|
||||
@@ -294,7 +402,7 @@ function Feed() {
|
||||
queryKey: ["tweets"],
|
||||
queryFn: async () => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "userId", "state"],
|
||||
fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }],
|
||||
sort: "-id",
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
|
||||
29
assets/js/upload.ts
Normal file
29
assets/js/upload.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface UploadResult {
|
||||
success: true;
|
||||
mediaId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface UploadError {
|
||||
success?: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
csrfToken: string
|
||||
): Promise<UploadResult | UploadError> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
// Do NOT set Content-Type — browser sets the multipart boundary automatically
|
||||
const res = await fetch("/upload", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
return { error: json.error ?? "Upload failed" };
|
||||
}
|
||||
return json as UploadResult;
|
||||
}
|
||||
Reference in New Issue
Block a user