Fix 413 Request Entity Too Large and refine image display aesthetics

This commit is contained in:
Patrick Britton 2026-02-04 13:32:46 -06:00
parent 3c91dc64c9
commit 92d8920183
8 changed files with 308 additions and 238 deletions

View file

@ -1,6 +1,8 @@
server {
server_name api.sojorn.net;
client_max_body_size 100M;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
@ -11,6 +13,11 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Increase timeouts for large file uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
listen 443 ssl; # managed by Certbot

View file

@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import '../services/auth_service.dart';
import '../services/api_service.dart';
import '../services/image_upload_service.dart';
import 'feed_refresh_provider.dart';
// Define the state class
class QuipUploadState {
@ -36,14 +37,13 @@ class QuipUploadState {
}
}
// Create a Notifier class for Riverpod 3.2.0+
class QuipUploadNotifier extends Notifier<QuipUploadState> {
@override
QuipUploadState build() {
return QuipUploadState(isUploading: false, progress: 0.0);
}
Future<void> startUpload(File videoFile, String caption) async {
Future<void> startUpload(File videoFile, String caption, {double? thumbnailTimestampMs}) async {
try {
state = state.copyWith(
isUploading: true, progress: 0.0, error: null, successMessage: null);
@ -60,8 +60,12 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
final tempDir = await getTemporaryDirectory();
final thumbnailPath = '${tempDir.path}/${timestamp}_thumb.jpg';
final ss = thumbnailTimestampMs != null
? (thumbnailTimestampMs / 1000.0).toStringAsFixed(3)
: '00:00:01';
final session = await FFmpegKit.execute(
'-y -ss 00:00:01 -i "${videoFile.path}" -vframes 1 -q:v 5 "$thumbnailPath"'
'-y -ss $ss -i "${videoFile.path}" -vframes 1 -q:v 2 "$thumbnailPath"'
);
final returnCode = await session.getReturnCode();
@ -102,7 +106,6 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
state = state.copyWith(progress: 0.8);
// Publish post via Go API
// Video goes to video_url, thumbnail to thumbnail_url
await ApiService.instance.publishPost(
body: caption,
videoUrl: videoUrl,
@ -110,10 +113,20 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
categoryId: null, // Default
);
// Trigger feed refresh
ref.read(feedRefreshProvider.notifier).state++;
state = state.copyWith(
isUploading: false,
progress: 1.0,
successMessage: 'Upload successful');
// Auto-reset after 3 seconds so UI goes back to + button
Future.delayed(const Duration(seconds: 3), () {
if (state.progress == 1.0 && !state.isUploading) {
state = QuipUploadState(isUploading: false, progress: 0.0);
}
});
} catch (e) {
state = state.copyWith(isUploading: false, error: e.toString());
}

View file

@ -112,49 +112,69 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
child: Stack(
alignment: Alignment.center,
children: [
// Outer Ring for Upload Progress
Consumer(
builder: (context, ref, child) {
final upload = ref.watch(quipUploadProvider);
final isDone = !upload.isUploading && upload.progress >= 1.0;
final isUploading = upload.isUploading;
final hasState = isUploading || isDone;
if (!upload.isUploading && upload.progress == 0) {
return const SizedBox.shrink();
}
return Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: hasState ? AppTheme.brightNavy : AppTheme.navyBlue,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: (hasState ? AppTheme.brightNavy : AppTheme.navyBlue).withOpacity(0.4),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
// Inside Border Progress
if (hasState)
SizedBox(
width: 50,
height: 50,
child: CustomPaint(
painter: _VerticalBorderProgressPainter(
progress: upload.progress,
color: Colors.white,
backgroundColor: Colors.white.withOpacity(0.2),
strokeWidth: 3.5,
borderRadius: 12,
),
),
),
return SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
value: upload.progress,
strokeWidth: 4,
backgroundColor: AppTheme.egyptianBlue.withOpacity(0.1),
valueColor: AlwaysStoppedAnimation<Color>(
upload.progress >= 0.99 ? Colors.green : AppTheme.brightNavy
),
// Content: Icon(+) or Percent or Check
if (isDone)
const Icon(Icons.check, color: Colors.white, size: 28)
else if (isUploading)
Text(
'${(upload.progress * 100).toInt()}%',
style: GoogleFonts.outfit(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
)
else
const Icon(
Icons.add,
color: Colors.white,
size: 32,
),
],
),
);
},
),
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppTheme.navyBlue,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.navyBlue.withOpacity(0.4),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.add,
color: Colors.white,
size: 32,
),
),
],
),
),
@ -331,6 +351,69 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
}
}
class _VerticalBorderProgressPainter extends CustomPainter {
final double progress;
final Color color;
final Color backgroundColor;
final double strokeWidth;
final double borderRadius;
_VerticalBorderProgressPainter({
required this.progress,
required this.color,
required this.backgroundColor,
this.strokeWidth = 3.0,
this.borderRadius = 16.0,
});
@override
void paint(Canvas canvas, Size size) {
final bgPaint = Paint()
..color = backgroundColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final rect = Rect.fromLTWH(
strokeWidth / 2,
strokeWidth / 2,
size.width - strokeWidth,
size.height - strokeWidth,
);
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius));
// Draw background border
canvas.drawRRect(rrect, bgPaint);
// Draw progress border
if (progress > 0) {
final progressPaint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
// Clip to vertical progress
canvas.save();
final clipRect = Rect.fromLTWH(
0,
size.height * (1.0 - progress),
size.width,
size.height * progress,
);
canvas.clipRect(clipRect);
canvas.drawRRect(rrect, progressPaint);
canvas.restore();
}
}
@override
bool shouldRepaint(covariant _VerticalBorderProgressPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.backgroundColor != backgroundColor;
}
}
/// Provides the current navigation shell index to descendants that need to
/// react (e.g. pausing quip playback when the tab is not active).
class NavigationShellScope extends InheritedWidget {

View file

@ -574,7 +574,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
width: double.infinity,
child: SignedMediaImage(
url: imageUrl,
fit: BoxFit.contain,
fit: BoxFit.cover,
),
),
),

View file

@ -5,7 +5,7 @@ import 'package:video_player/video_player.dart';
import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:path_provider/path_provider.dart';
// import '../../../providers/quip_upload_provider.dart'; // Removed missing provider
import '../../../providers/quip_upload_provider.dart';
import '../../../services/image_upload_service.dart';
import '../../../providers/api_provider.dart';
import '../../../providers/feed_refresh_provider.dart';
@ -23,8 +23,8 @@ class _QuipMetadataScreenState extends ConsumerState<QuipMetadataScreen> {
late VideoPlayerController _controller;
final TextEditingController _captionController = TextEditingController();
double _coverTimestamp = 0.0;
bool _isUploading = false;
final ImageUploadService _uploadService = ImageUploadService();
// bool _isUploading = false;
// final ImageUploadService _uploadService = ImageUploadService();
@override
void initState() {
@ -41,77 +41,25 @@ class _QuipMetadataScreenState extends ConsumerState<QuipMetadataScreen> {
}
Future<void> _postQuip() async {
if (_isUploading) return;
setState(() => _isUploading = true);
final uploadNotifier = ref.read(quipUploadProvider.notifier);
try {
// 1. Generate Thumbnail
String? thumbnailUrl;
try {
final tempDir = await getTemporaryDirectory();
final thumbPath = '${tempDir.path}/thumbnail_${DateTime.now().millisecondsSinceEpoch}.jpg';
final seconds = _coverTimestamp / 1000.0;
// We already have the logic to generate a specific thumbnail in the provider,
// but the screen allows choosing a timestamp.
// To support the chosen cover, we'll generate it here and then pass it
// or just pass the timestamp to the provider.
// Let's pass the chosen timestamp to startUpload.
// Execute FFmpeg to extract frame
final session = await FFmpegKit.executeWithArguments([
'-y',
'-user_agent', 'SojornApp/1.0',
'-ss', seconds.toStringAsFixed(3),
'-i', widget.videoFile.path,
'-vframes', '1',
'-q:v', '5',
thumbPath
]);
final returnCode = await session.getReturnCode();
uploadNotifier.startUpload(
widget.videoFile,
_captionController.text.trim(),
thumbnailTimestampMs: _coverTimestamp,
);
if (ReturnCode.isSuccess(returnCode)) {
final thumbFile = File(thumbPath);
if (await thumbFile.exists()) {
thumbnailUrl = await _uploadService.uploadImage(thumbFile);
}
} else {
final logs = await session.getLogs();
final logContent = logs.map((l) => l.getMessage()).join('\n');
print('FFmpeg thumbnail failed. ReturnCode: $returnCode');
print('Logs:\n$logContent');
}
} catch (e) {
print('Thumbnail generation error: $e');
// Continue without thumbnail
}
// 2. Upload Video
final videoUrl = await _uploadService.uploadVideo(
widget.videoFile,
onProgress: (progress) {
// Optional: Update progress UI
},
if (mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Upload started in background")),
);
// 3. Create the Post in the database
final apiService = ref.read(apiServiceProvider);
await apiService.publishPost(
body: _captionController.text.trim(),
videoUrl: videoUrl,
thumbnailUrl: thumbnailUrl,
);
if (mounted) {
// Refresh feed to show new quip
ref.read(feedRefreshProvider.notifier).state++;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Quip posted successfully!")),
);
Navigator.of(context).popUntil((route) => route.isFirst);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Upload failed: $e")),
);
setState(() => _isUploading = false);
}
}
}
@ -135,14 +83,12 @@ class _QuipMetadataScreenState extends ConsumerState<QuipMetadataScreen> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: ElevatedButton(
onPressed: _isUploading ? null : _postQuip,
onPressed: _postQuip,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy,
shape: const StadiumBorder(),
),
child: _isUploading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text("Post"),
child: const Text("Post"),
),
)
],

View file

@ -486,14 +486,16 @@ class NotificationService {
break;
case 'like':
case 'quip_reaction':
case 'save':
case 'comment':
case 'reply':
case 'mention':
final postId = data['post_id'];
case 'share':
final postId = data['post_id'] ?? data['beacon_id'];
final target = data['target'];
if (postId != null) {
_navigateToPost(navigator, postId, target);
_navigateToPost(navigator, postId.toString(), target?.toString());
}
break;
@ -503,7 +505,7 @@ class NotificationService {
case 'follow_accepted':
final followerId = data['follower_id'];
if (followerId != null) {
navigator.context.push('/u/$followerId');
navigator.context.push('${AppRoutes.userPrefix}/$followerId');
} else {
navigator.context.go(AppRoutes.profile);
}
@ -511,7 +513,12 @@ class NotificationService {
case 'beacon_vouch':
case 'beacon_report':
navigator.context.go(AppRoutes.beaconPrefix);
final beaconId = data['beacon_id'] ?? data['post_id'];
if (beaconId != null) {
_navigateToPost(navigator, beaconId.toString(), 'beacon_map');
} else {
navigator.context.go(AppRoutes.beaconPrefix);
}
break;
default:

View file

@ -44,31 +44,28 @@ class PostMedia extends StatelessWidget {
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: _imageHeight),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: SizedBox(
width: double.infinity,
child: SignedMediaImage(
url: post!.imageUrl!,
fit: BoxFit.contain,
loadingBuilder: (context) => Container(
color: AppTheme.queenPink.withValues(alpha: 0.3),
child: const Center(child: CircularProgressIndicator()),
),
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.red.withValues(alpha: 0.3),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.broken_image,
size: 48, color: Colors.white),
const SizedBox(height: 8),
Text('Error: $error',
style: const TextStyle(
color: Colors.white, fontSize: 10)),
],
),
child: SizedBox(
width: double.infinity,
child: SignedMediaImage(
url: post!.imageUrl!,
fit: BoxFit.cover,
loadingBuilder: (context) => Container(
color: AppTheme.queenPink.withValues(alpha: 0.3),
child: const Center(child: CircularProgressIndicator()),
),
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.red.withValues(alpha: 0.3),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.broken_image,
size: 48, color: Colors.white),
const SizedBox(height: 8),
Text('Error: $error',
style: const TextStyle(
color: Colors.white, fontSize: 10)),
],
),
),
),

View file

@ -87,7 +87,6 @@ class sojornPostCard extends StatelessWidget {
color: Colors.transparent,
child: Container(
margin: const EdgeInsets.only(bottom: 16), // Add spacing between cards
padding: _padding,
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(20),
@ -103,109 +102,127 @@ class sojornPostCard extends StatelessWidget {
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chain Context (The Quote Box) - only show in thread view
if (isThreadView && showChainContext && post.chainParent != null) ...[
ChainQuoteWidget(
parent: post.chainParent!,
onTap: onChainParentTap,
),
const SizedBox(height: AppTheme.spacingSm),
],
// Main Post Content
const SizedBox(height: 4),
// Header row with menu - only header is clickable for profile
Row(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
padding: _padding.copyWith(left: 0, right: 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: InkWell(
onTap: () {
final handle = post.author?.handle ?? 'unknown';
if (handle != 'unknown' && handle.trim().isNotEmpty) {
AppRoutes.navigateToProfile(context, handle);
}
},
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
// Internal horizontal padding for text/actions
Padding(
padding: EdgeInsets.symmetric(horizontal: _padding.left),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chain Context (The Quote Box) - only show in thread view
if (isThreadView && showChainContext && post.chainParent != null) ...[
ChainQuoteWidget(
parent: post.chainParent!,
onTap: onChainParentTap,
),
const SizedBox(height: AppTheme.spacingSm),
],
// Main Post Content
const SizedBox(height: 4),
// Header row with menu - only header is clickable for profile
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: InkWell(
onTap: () {
final handle = post.author?.handle ?? 'unknown';
if (handle != 'unknown' && handle.trim().isNotEmpty) {
AppRoutes.navigateToProfile(context, handle);
}
},
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: PostHeader(
post: post,
avatarSize: _avatarSize,
mode: mode,
),
),
),
),
GestureDetector(
onTap: () => SanctuarySheet.show(context, post),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.ksuPurple.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text("!", style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: AppTheme.royalPurple.withOpacity(0.7),
)),
),
),
PostMenu(
post: post,
onPostDeleted: onPostChanged,
),
],
),
child: PostHeader(
post: post,
avatarSize: _avatarSize,
mode: mode,
const SizedBox(height: 16),
// Body text - clickable for post detail with full background coverage
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 4),
child: PostBody(
text: post.body,
bodyFormat: post.bodyFormat,
backgroundId: post.backgroundId,
mode: mode,
),
),
),
),
],
),
),
GestureDetector(
onTap: () => SanctuarySheet.show(context, post),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.ksuPurple.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
// Media (if available) - clickable for post detail
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
const SizedBox(height: 12),
InkWell(
onTap: onTap,
child: PostMedia(
post: post,
mode: mode,
),
child: Text("!", style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: AppTheme.royalPurple.withOpacity(0.7),
)),
),
],
// Actions section - with padding
const SizedBox(height: 16),
Padding(
padding: EdgeInsets.symmetric(horizontal: _padding.left),
child: PostActions(
post: post,
onChain: onChain,
onPostChanged: onPostChanged,
isThreadView: isThreadView,
showReactions: isThreadView,
),
),
PostMenu(
post: post,
onPostDeleted: onPostChanged,
),
const SizedBox(height: 4),
],
),
const SizedBox(height: 16),
// Body text - clickable for post detail with full background coverage
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 4),
child: PostBody(
text: post.body,
bodyFormat: post.bodyFormat,
backgroundId: post.backgroundId,
mode: mode,
),
),
),
// Media (if available) - clickable for post detail
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
const SizedBox(height: 16),
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: PostMedia(
post: post,
mode: mode,
),
),
],
const SizedBox(height: 20),
// Actions
PostActions(
post: post,
onChain: onChain,
onPostChanged: onPostChanged,
isThreadView: isThreadView,
showReactions: isThreadView,
),
],
),
),
),
);