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 {
server_name api.sojorn.net; server_name api.sojorn.net;
client_max_body_size 100M;
location / { location / {
proxy_pass http://localhost:8080; proxy_pass http://localhost:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
@ -11,6 +13,11 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "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 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/auth_service.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
import '../services/image_upload_service.dart'; import '../services/image_upload_service.dart';
import 'feed_refresh_provider.dart';
// Define the state class // Define the state class
class QuipUploadState { class QuipUploadState {
@ -36,14 +37,13 @@ class QuipUploadState {
} }
} }
// Create a Notifier class for Riverpod 3.2.0+
class QuipUploadNotifier extends Notifier<QuipUploadState> { class QuipUploadNotifier extends Notifier<QuipUploadState> {
@override @override
QuipUploadState build() { QuipUploadState build() {
return QuipUploadState(isUploading: false, progress: 0.0); 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 { try {
state = state.copyWith( state = state.copyWith(
isUploading: true, progress: 0.0, error: null, successMessage: null); isUploading: true, progress: 0.0, error: null, successMessage: null);
@ -60,8 +60,12 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final thumbnailPath = '${tempDir.path}/${timestamp}_thumb.jpg'; final thumbnailPath = '${tempDir.path}/${timestamp}_thumb.jpg';
final ss = thumbnailTimestampMs != null
? (thumbnailTimestampMs / 1000.0).toStringAsFixed(3)
: '00:00:01';
final session = await FFmpegKit.execute( 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(); final returnCode = await session.getReturnCode();
@ -102,7 +106,6 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
state = state.copyWith(progress: 0.8); state = state.copyWith(progress: 0.8);
// Publish post via Go API // Publish post via Go API
// Video goes to video_url, thumbnail to thumbnail_url
await ApiService.instance.publishPost( await ApiService.instance.publishPost(
body: caption, body: caption,
videoUrl: videoUrl, videoUrl: videoUrl,
@ -110,10 +113,20 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
categoryId: null, // Default categoryId: null, // Default
); );
// Trigger feed refresh
ref.read(feedRefreshProvider.notifier).state++;
state = state.copyWith( state = state.copyWith(
isUploading: false, isUploading: false,
progress: 1.0, progress: 1.0,
successMessage: 'Upload successful'); 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) { } catch (e) {
state = state.copyWith(isUploading: false, error: e.toString()); state = state.copyWith(isUploading: false, error: e.toString());
} }

View file

@ -112,49 +112,69 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
// Outer Ring for Upload Progress
Consumer( Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final upload = ref.watch(quipUploadProvider); 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 Container(
return const SizedBox.shrink(); 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( // Content: Icon(+) or Percent or Check
width: 64, if (isDone)
height: 64, const Icon(Icons.check, color: Colors.white, size: 28)
child: CircularProgressIndicator( else if (isUploading)
value: upload.progress, Text(
strokeWidth: 4, '${(upload.progress * 100).toInt()}%',
backgroundColor: AppTheme.egyptianBlue.withOpacity(0.1), style: GoogleFonts.outfit(
valueColor: AlwaysStoppedAnimation<Color>( color: Colors.white,
upload.progress >= 0.99 ? Colors.green : AppTheme.brightNavy 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 /// Provides the current navigation shell index to descendants that need to
/// react (e.g. pausing quip playback when the tab is not active). /// react (e.g. pausing quip playback when the tab is not active).
class NavigationShellScope extends InheritedWidget { class NavigationShellScope extends InheritedWidget {

View file

@ -574,7 +574,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
width: double.infinity, width: double.infinity,
child: SignedMediaImage( child: SignedMediaImage(
url: imageUrl, 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/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new/return_code.dart'; import 'package:ffmpeg_kit_flutter_new/return_code.dart';
import 'package:path_provider/path_provider.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 '../../../services/image_upload_service.dart';
import '../../../providers/api_provider.dart'; import '../../../providers/api_provider.dart';
import '../../../providers/feed_refresh_provider.dart'; import '../../../providers/feed_refresh_provider.dart';
@ -23,8 +23,8 @@ class _QuipMetadataScreenState extends ConsumerState<QuipMetadataScreen> {
late VideoPlayerController _controller; late VideoPlayerController _controller;
final TextEditingController _captionController = TextEditingController(); final TextEditingController _captionController = TextEditingController();
double _coverTimestamp = 0.0; double _coverTimestamp = 0.0;
bool _isUploading = false; // bool _isUploading = false;
final ImageUploadService _uploadService = ImageUploadService(); // final ImageUploadService _uploadService = ImageUploadService();
@override @override
void initState() { void initState() {
@ -41,77 +41,25 @@ class _QuipMetadataScreenState extends ConsumerState<QuipMetadataScreen> {
} }
Future<void> _postQuip() async { Future<void> _postQuip() async {
if (_isUploading) return; final uploadNotifier = ref.read(quipUploadProvider.notifier);
setState(() => _isUploading = true);
try { // We already have the logic to generate a specific thumbnail in the provider,
// 1. Generate Thumbnail // but the screen allows choosing a timestamp.
String? thumbnailUrl; // To support the chosen cover, we'll generate it here and then pass it
try { // or just pass the timestamp to the provider.
final tempDir = await getTemporaryDirectory(); // Let's pass the chosen timestamp to startUpload.
final thumbPath = '${tempDir.path}/thumbnail_${DateTime.now().millisecondsSinceEpoch}.jpg';
final seconds = _coverTimestamp / 1000.0;
// Execute FFmpeg to extract frame uploadNotifier.startUpload(
final session = await FFmpegKit.executeWithArguments([ widget.videoFile,
'-y', _captionController.text.trim(),
'-user_agent', 'SojornApp/1.0', thumbnailTimestampMs: _coverTimestamp,
'-ss', seconds.toStringAsFixed(3), );
'-i', widget.videoFile.path,
'-vframes', '1',
'-q:v', '5',
thumbPath
]);
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) { if (mounted) {
final thumbFile = File(thumbPath); Navigator.of(context).popUntil((route) => route.isFirst);
if (await thumbFile.exists()) { ScaffoldMessenger.of(context).showSnackBar(
thumbnailUrl = await _uploadService.uploadImage(thumbFile); const SnackBar(content: Text("Upload started in background")),
}
} 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
},
); );
// 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(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: ElevatedButton( child: ElevatedButton(
onPressed: _isUploading ? null : _postQuip, onPressed: _postQuip,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.brightNavy, backgroundColor: AppTheme.brightNavy,
shape: const StadiumBorder(), shape: const StadiumBorder(),
), ),
child: _isUploading child: const Text("Post"),
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text("Post"),
), ),
) )
], ],

View file

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

View file

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

View file

@ -87,7 +87,6 @@ class sojornPostCard extends StatelessWidget {
color: Colors.transparent, color: Colors.transparent,
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 16), // Add spacing between cards margin: const EdgeInsets.only(bottom: 16), // Add spacing between cards
padding: _padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.cardSurface, color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -103,109 +102,127 @@ class sojornPostCard extends StatelessWidget {
), ),
], ],
), ),
child: Column( child: ClipRRect(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(20),
children: [ child: Container(
// Chain Context (The Quote Box) - only show in thread view padding: _padding.copyWith(left: 0, right: 0),
if (isThreadView && showChainContext && post.chainParent != null) ...[ child: Column(
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( // Internal horizontal padding for text/actions
child: InkWell( Padding(
onTap: () { padding: EdgeInsets.symmetric(horizontal: _padding.left),
final handle = post.author?.handle ?? 'unknown'; child: Column(
if (handle != 'unknown' && handle.trim().isNotEmpty) { crossAxisAlignment: CrossAxisAlignment.start,
AppRoutes.navigateToProfile(context, handle); children: [
} // Chain Context (The Quote Box) - only show in thread view
}, if (isThreadView && showChainContext && post.chainParent != null) ...[
borderRadius: BorderRadius.circular(AppTheme.radiusMd), ChainQuoteWidget(
child: Padding( parent: post.chainParent!,
padding: const EdgeInsets.symmetric( onTap: onChainParentTap,
horizontal: 4, ),
vertical: 4, 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( const SizedBox(height: 16),
post: post,
avatarSize: _avatarSize, // Body text - clickable for post detail with full background coverage
mode: mode, 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), // Media (if available) - clickable for post detail
child: Container( if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
margin: const EdgeInsets.symmetric(horizontal: 4), const SizedBox(height: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), InkWell(
decoration: BoxDecoration( onTap: onTap,
color: AppTheme.ksuPurple.withOpacity(0.1), child: PostMedia(
borderRadius: BorderRadius.circular(12), 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( const SizedBox(height: 4),
post: post,
onPostDeleted: onPostChanged,
),
], ],
), ),
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,
),
],
), ),
), ),
); );