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,48 +112,68 @@ 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();
}
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
),
),
);
},
),
Container(
width: 56, width: 56,
height: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.navyBlue, color: hasState ? AppTheme.brightNavy : AppTheme.navyBlue,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppTheme.navyBlue.withOpacity(0.4), color: (hasState ? AppTheme.brightNavy : AppTheme.navyBlue).withOpacity(0.4),
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
], ],
), ),
child: const Icon( 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,
),
),
),
// 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, Icons.add,
color: Colors.white, color: Colors.white,
size: 32, 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([
'-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();
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, widget.videoFile,
onProgress: (progress) { _captionController.text.trim(),
// Optional: Update progress UI thumbnailTimestampMs: _coverTimestamp,
},
);
// 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) { 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); Navigator.of(context).popUntil((route) => route.isFirst);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Upload failed: $e")), const SnackBar(content: Text("Upload started in background")),
); );
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':
final beaconId = data['beacon_id'] ?? data['post_id'];
if (beaconId != null) {
_navigateToPost(navigator, beaconId.toString(), 'beacon_map');
} else {
navigator.context.go(AppRoutes.beaconPrefix); navigator.context.go(AppRoutes.beaconPrefix);
}
break; break;
default: default:

View file

@ -44,13 +44,11 @@ class PostMedia extends StatelessWidget {
children: [ children: [
ConstrainedBox( ConstrainedBox(
constraints: BoxConstraints(maxHeight: _imageHeight), constraints: BoxConstraints(maxHeight: _imageHeight),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: SignedMediaImage( child: SignedMediaImage(
url: post!.imageUrl!, url: post!.imageUrl!,
fit: BoxFit.contain, fit: BoxFit.cover,
loadingBuilder: (context) => Container( loadingBuilder: (context) => Container(
color: AppTheme.queenPink.withValues(alpha: 0.3), color: AppTheme.queenPink.withValues(alpha: 0.3),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
@ -74,7 +72,6 @@ class PostMedia extends StatelessWidget {
), ),
), ),
), ),
),
], ],
), ),
); );

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,6 +102,16 @@ class sojornPostCard extends StatelessWidget {
), ),
], ],
), ),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
padding: _padding.copyWith(left: 0, right: 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Internal horizontal padding for text/actions
Padding(
padding: EdgeInsets.symmetric(horizontal: _padding.left),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -182,32 +191,40 @@ class sojornPostCard extends StatelessWidget {
), ),
), ),
), ),
],
),
),
// Media (if available) - clickable for post detail // Media (if available) - clickable for post detail
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[ if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 12),
InkWell( InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: PostMedia( child: PostMedia(
post: post, post: post,
mode: mode, mode: mode,
), ),
), ),
], ],
const SizedBox(height: 20),
// Actions // Actions section - with padding
PostActions( const SizedBox(height: 16),
Padding(
padding: EdgeInsets.symmetric(horizontal: _padding.left),
child: PostActions(
post: post, post: post,
onChain: onChain, onChain: onChain,
onPostChanged: onPostChanged, onPostChanged: onPostChanged,
isThreadView: isThreadView, isThreadView: isThreadView,
showReactions: isThreadView, showReactions: isThreadView,
), ),
),
const SizedBox(height: 4),
], ],
), ),
), ),
),
),
); );
} }
} }