feat: add S3 configuration and utility functions for blog image handling
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 17s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 17s
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# S3 Configuration for Blog Images
|
||||||
|
VITE_S3_ENDPOINT_BLOG=https://s3.example.com
|
||||||
|
VITE_S3_BUCKET_BLOG=my-blog-bucket
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -19,6 +19,9 @@ env:
|
|||||||
CONTAINER_REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
|
CONTAINER_REGISTRY_PASSWORD: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}
|
||||||
CONTAINER_IMAGE_NAME: ${{ secrets.CONTAINER_IMAGE_NAME }}
|
CONTAINER_IMAGE_NAME: ${{ secrets.CONTAINER_IMAGE_NAME }}
|
||||||
CONTAINER_IMAGE_TAG: ${{ secrets.CONTAINER_IMAGE_TAG }}
|
CONTAINER_IMAGE_TAG: ${{ secrets.CONTAINER_IMAGE_TAG }}
|
||||||
|
# S3 Configuration for Blog
|
||||||
|
VITE_S3_ENDPOINT_BLOG: ${{ secrets.VITE_S3_ENDPOINT_BLOG }}
|
||||||
|
VITE_S3_BUCKET_BLOG: ${{ secrets.VITE_S3_BUCKET_BLOG }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -12,6 +12,11 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ RUN npm ci
|
|||||||
# Copy project files
|
# Copy project files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build arguments for environment variables
|
||||||
|
ARG VITE_S3_ENDPOINT_BLOG
|
||||||
|
ARG VITE_S3_BUCKET_BLOG
|
||||||
|
|
||||||
|
# Set environment variables for Vite build
|
||||||
|
ENV VITE_S3_ENDPOINT_BLOG=${VITE_S3_ENDPOINT_BLOG}
|
||||||
|
ENV VITE_S3_BUCKET_BLOG=${VITE_S3_BUCKET_BLOG}
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
20
script/ci.sh
20
script/ci.sh
@@ -13,6 +13,10 @@ CONTAINER_REGISTRY_PASSWORD="${CONTAINER_REGISTRY_PASSWORD:-password}"
|
|||||||
CONTAINER_IMAGE_NAME="${CONTAINER_IMAGE_NAME:-image-name}"
|
CONTAINER_IMAGE_NAME="${CONTAINER_IMAGE_NAME:-image-name}"
|
||||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG:-latest}"
|
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG:-latest}"
|
||||||
|
|
||||||
|
# Application Environment Variables
|
||||||
|
VITE_S3_ENDPOINT_BLOG="${VITE_S3_ENDPOINT_BLOG:-}"
|
||||||
|
VITE_S3_BUCKET_BLOG="${VITE_S3_BUCKET_BLOG:-}"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Functions
|
# Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -33,6 +37,8 @@ print_help() {
|
|||||||
echo " CONTAINER_REGISTRY_PASSWORD Registry password (default: password)"
|
echo " CONTAINER_REGISTRY_PASSWORD Registry password (default: password)"
|
||||||
echo " CONTAINER_IMAGE_NAME Image name (default: image-name)"
|
echo " CONTAINER_IMAGE_NAME Image name (default: image-name)"
|
||||||
echo " CONTAINER_IMAGE_TAG Image tag (default: latest)"
|
echo " CONTAINER_IMAGE_TAG Image tag (default: latest)"
|
||||||
|
echo " VITE_S3_ENDPOINT_BLOG S3 endpoint for blog images"
|
||||||
|
echo " VITE_S3_BUCKET_BLOG S3 bucket for blog images"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 build"
|
echo " $0 build"
|
||||||
@@ -51,8 +57,20 @@ build_image() {
|
|||||||
echo "Image: ${FULL_IMAGE_NAME}"
|
echo "Image: ${FULL_IMAGE_NAME}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Prepare build args for S3 configuration
|
||||||
|
BUILD_ARGS=""
|
||||||
|
if [ -n "${VITE_S3_ENDPOINT_BLOG}" ]; then
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VITE_S3_ENDPOINT_BLOG=${VITE_S3_ENDPOINT_BLOG}"
|
||||||
|
echo "S3 Endpoint (Blog): ${VITE_S3_ENDPOINT_BLOG}"
|
||||||
|
fi
|
||||||
|
if [ -n "${VITE_S3_BUCKET_BLOG}" ]; then
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VITE_S3_BUCKET_BLOG=${VITE_S3_BUCKET_BLOG}"
|
||||||
|
echo "S3 Bucket (Blog): ${VITE_S3_BUCKET_BLOG}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Build the Docker image using Dockerfile in current directory
|
# Build the Docker image using Dockerfile in current directory
|
||||||
docker build -t "${FULL_IMAGE_NAME}" .
|
docker build ${BUILD_ARGS} -t "${FULL_IMAGE_NAME}" .
|
||||||
|
|
||||||
# Check if build was successful
|
# Check if build was successful
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiRequest } from '../utils/api';
|
import { apiRequest } from '../utils/api';
|
||||||
|
import { getS3Url } from './s3Config';
|
||||||
import type {
|
import type {
|
||||||
UploadPresignedURLResponse,
|
UploadPresignedURLResponse,
|
||||||
DownloadPresignedURLResponse,
|
|
||||||
CreatePostResponse,
|
CreatePostResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -27,22 +27,6 @@ export async function getUploadPresignedURL(fileName: string): Promise<UploadPre
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get presigned URL for file download
|
|
||||||
*/
|
|
||||||
export async function getDownloadPresignedURL(fileKey: string): Promise<DownloadPresignedURLResponse> {
|
|
||||||
const response = await apiRequest(`${API_BASE}/file/download`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ fileKey }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to get download URL: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload file to S3 using presigned URL
|
* Upload file to S3 using presigned URL
|
||||||
*/
|
*/
|
||||||
@@ -97,10 +81,10 @@ export async function uploadImage(
|
|||||||
// Step 2: Upload file to S3
|
// Step 2: Upload file to S3
|
||||||
await uploadFileToS3(uploadUrl, file, onProgress);
|
await uploadFileToS3(uploadUrl, file, onProgress);
|
||||||
|
|
||||||
// Step 3: Get download URL
|
// Step 3: Generate public S3 URL from fileKey
|
||||||
const { url: downloadUrl } = await getDownloadPresignedURL(fileKey);
|
const url = getS3Url(fileKey);
|
||||||
|
|
||||||
return { fileKey, url: downloadUrl };
|
return { fileKey, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useMemo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import ImageResizer from './ImageResizer';
|
import ImageResizer from './ImageResizer';
|
||||||
import { $isImageNode } from './ImageNode';
|
import { $isImageNode } from './ImageNode';
|
||||||
|
import { getS3Url } from '../s3Config';
|
||||||
|
|
||||||
type ImageStatus =
|
type ImageStatus =
|
||||||
| { error: true }
|
| { error: true }
|
||||||
@@ -143,6 +145,14 @@ export default function ImageComponent({
|
|||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [isLoadError, setIsLoadError] = useState<boolean>(false);
|
const [isLoadError, setIsLoadError] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Generate actual image URL from fileKey or use src
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
if (_fileKey) {
|
||||||
|
return getS3Url(_fileKey);
|
||||||
|
}
|
||||||
|
return src; // Fallback to src for data URLs during upload
|
||||||
|
}, [_fileKey, src]);
|
||||||
|
|
||||||
const $onEscape = useCallback(
|
const $onEscape = useCallback(
|
||||||
() => {
|
() => {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -259,7 +269,7 @@ export default function ImageComponent({
|
|||||||
) : (
|
) : (
|
||||||
<LazyImage
|
<LazyImage
|
||||||
className={isFocused ? 'focused' : null}
|
className={isFocused ? 'focused' : null}
|
||||||
src={src}
|
src={imageUrl}
|
||||||
altText={altText}
|
altText={altText}
|
||||||
imageRef={imageRef}
|
imageRef={imageRef}
|
||||||
width={width}
|
width={width}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export type SerializedImageNode = Spread<
|
|||||||
altText: string;
|
altText: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
maxWidth: number;
|
maxWidth: number;
|
||||||
src: string;
|
|
||||||
width?: number;
|
width?: number;
|
||||||
fileKey?: string;
|
fileKey?: string;
|
||||||
uploadProgress?: number;
|
uploadProgress?: number;
|
||||||
@@ -85,7 +84,11 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static importJSON(serializedNode: SerializedImageNode): ImageNode {
|
static importJSON(serializedNode: SerializedImageNode): ImageNode {
|
||||||
const { altText, height, width, maxWidth, src, fileKey, uploadProgress, uploadError } = serializedNode;
|
const { altText, height, width, maxWidth, fileKey, uploadProgress, uploadError } = serializedNode;
|
||||||
|
|
||||||
|
// Generate src from fileKey if available, otherwise use empty string
|
||||||
|
const src = fileKey ? '' : ''; // src will be generated from fileKey when rendering
|
||||||
|
|
||||||
return $createImageNode({
|
return $createImageNode({
|
||||||
altText,
|
altText,
|
||||||
height,
|
height,
|
||||||
@@ -150,7 +153,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
|
|||||||
altText: this.getAltText(),
|
altText: this.getAltText(),
|
||||||
height: this.__height === 'inherit' ? 0 : this.__height,
|
height: this.__height === 'inherit' ? 0 : this.__height,
|
||||||
maxWidth: this.__maxWidth,
|
maxWidth: this.__maxWidth,
|
||||||
src: this.getSrc(),
|
// Don't export src - it's mutable and will be generated from fileKey
|
||||||
type: 'image',
|
type: 'image',
|
||||||
version: 1,
|
version: 1,
|
||||||
width: this.__width === 'inherit' ? 0 : this.__width,
|
width: this.__width === 'inherit' ? 0 : this.__width,
|
||||||
|
|||||||
39
src/blog/s3Config.ts
Normal file
39
src/blog/s3Config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* S3 configuration utility for Blog
|
||||||
|
*/
|
||||||
|
|
||||||
|
// S3 endpoint and bucket from environment variables
|
||||||
|
export const S3_ENDPOINT_BLOG = import.meta.env.VITE_S3_ENDPOINT_BLOG || 'https://s3.amazonaws.com';
|
||||||
|
export const S3_BUCKET_BLOG = import.meta.env.VITE_S3_BUCKET_BLOG || 'default-bucket';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate S3 URL from file key
|
||||||
|
* @param fileKey - The S3 file key (e.g., "uploads/image-123.png")
|
||||||
|
* @returns Full S3 URL to access the file
|
||||||
|
*/
|
||||||
|
export function getS3Url(fileKey: string): string {
|
||||||
|
// Remove leading slash if present
|
||||||
|
const key = fileKey.startsWith('/') ? fileKey.slice(1) : fileKey;
|
||||||
|
|
||||||
|
// Construct URL: https://s3.example.com/bucket-name/uploads/image-123.png
|
||||||
|
return `${S3_ENDPOINT_BLOG}/${S3_BUCKET_BLOG}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file key from S3 URL
|
||||||
|
* @param url - Full S3 URL
|
||||||
|
* @returns File key or null if not a valid S3 URL
|
||||||
|
*/
|
||||||
|
export function extractFileKeyFromUrl(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathParts = urlObj.pathname.split('/');
|
||||||
|
|
||||||
|
// Remove empty parts and bucket name
|
||||||
|
const filteredParts = pathParts.filter(p => p && p !== S3_BUCKET_BLOG);
|
||||||
|
|
||||||
|
return filteredParts.join('/') || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,6 @@ export interface UploadPresignedURLResponse {
|
|||||||
expireAt: number;
|
expireAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadPresignedURLResponse {
|
|
||||||
url: string;
|
|
||||||
expireAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePostResponse {
|
export interface CreatePostResponse {
|
||||||
postId: string;
|
postId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user