feat(front-hall): implement three-mode layout with quick links panel

Phase 2 of Organizr Migration - Front Hall restructure:

- Add FrontHallState provider with three modes (dashboard, iframe, settings)
- Create QuickLinksPanel widget with categorized links and overflow menus
- Add IframeView with platform-aware implementation (web iframe, mobile fallback)
- Extract DashboardContent from FrontHallPage
- Add QuickLinkSettingsContent placeholder for Phase 4 link editor
- Implement QuickLink entity and data layer with Core API datasource
- Add default quick links fallback when API unavailable
- Update UI_LAYOUT.md with Front Hall panel configurations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-02 22:24:20 +01:00
co-authored by Claude Opus 4.5
parent 4f8d0f521f
commit 40db92d9d5
13 changed files with 1693 additions and 133 deletions
@@ -0,0 +1,82 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'front_hall_state_provider.freezed.dart';
part 'front_hall_state_provider.g.dart';
/// Front Hall content display mode.
enum FrontHallMode {
/// Shows dashboard widgets (stats, weather, etc.).
dashboard,
/// Shows embedded iframe content.
iframe,
/// Shows link management/settings UI.
settings,
}
/// State for Front Hall content area.
@freezed
sealed class FrontHallState with _$FrontHallState {
const factory FrontHallState({
/// Current display mode.
@Default(FrontHallMode.dashboard) FrontHallMode mode,
/// Active iframe URL (when mode is iframe).
String? activeIframeUrl,
/// Active iframe title (when mode is iframe).
String? activeIframeTitle,
/// Selected link ID for editing (when mode is settings).
/// Null means creating a new link.
String? selectedLinkId,
}) = _FrontHallState;
const FrontHallState._();
}
/// Controller for Front Hall state.
@riverpod
class FrontHallStateNotifier extends _$FrontHallStateNotifier {
@override
FrontHallState build() => const FrontHallState();
/// Shows the dashboard (default mode).
void showDashboard() {
state = const FrontHallState();
}
/// Shows an iframe with the given URL and title.
void showIframe(String url, String title) {
state = FrontHallState(
mode: FrontHallMode.iframe,
activeIframeUrl: url,
activeIframeTitle: title,
);
}
/// Enters settings mode, optionally selecting a link for editing.
void enterSettings({String? linkId}) {
state = FrontHallState(
mode: FrontHallMode.settings,
selectedLinkId: linkId,
);
}
/// Selects a link for editing (while in settings mode).
void selectLink(String linkId) {
state = state.copyWith(selectedLinkId: linkId);
}
/// Clears the selected link (for creating new).
void clearSelectedLink() {
state = state.copyWith(selectedLinkId: null);
}
/// Exits settings mode and returns to dashboard.
void exitSettings() {
state = const FrontHallState();
}
}
@@ -0,0 +1,192 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/front_hall/data/datasources/quick_links_datasource.dart';
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
part 'quick_links_provider.g.dart';
/// Provides the list of all quick links.
/// Falls back to default links if API is unavailable.
@riverpod
Future<List<QuickLink>> quickLinks(Ref ref) async {
try {
final datasource = ref.watch(quickLinksDatasourceProvider);
final links = await datasource.getQuickLinks();
// Return defaults if API returns empty list
return links.isEmpty ? getDefaultQuickLinks() : links;
} catch (_) {
// Return default links if API fails (e.g., backend not running)
return getDefaultQuickLinks();
}
}
/// Provides a single quick link by ID.
/// Falls back to default links if API is unavailable.
@riverpod
Future<QuickLink> quickLink(Ref ref, String id) async {
// First try to find in defaults (works offline)
final defaults = getDefaultQuickLinks();
final defaultLink = defaults.where((link) => link.id == id).firstOrNull;
if (defaultLink != null) {
return defaultLink;
}
// If not in defaults, try API
try {
final datasource = ref.watch(quickLinksDatasourceProvider);
return await datasource.getQuickLink(id);
} catch (_) {
throw Exception('Link not found: $id');
}
}
/// Controller for quick link CRUD operations.
@riverpod
class QuickLinkActions extends _$QuickLinkActions {
@override
AsyncValue<void> build() => const AsyncValue.data(null);
/// Creates a new quick link.
Future<QuickLink?> create(QuickLink link) async {
state = const AsyncValue.loading();
QuickLink? result;
state = await AsyncValue.guard(() async {
final datasource = ref.read(quickLinksDatasourceProvider);
result = await datasource.createQuickLink(link);
ref.invalidate(quickLinksProvider);
});
return result;
}
/// Updates an existing quick link.
Future<QuickLink?> update(QuickLink link) async {
state = const AsyncValue.loading();
QuickLink? result;
state = await AsyncValue.guard(() async {
final datasource = ref.read(quickLinksDatasourceProvider);
result = await datasource.updateQuickLink(link);
ref.invalidate(quickLinksProvider);
ref.invalidate(quickLinkProvider(link.id));
});
return result;
}
/// Deletes a quick link.
Future<void> delete(String id) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final datasource = ref.read(quickLinksDatasourceProvider);
await datasource.deleteQuickLink(id);
ref.invalidate(quickLinksProvider);
});
}
/// Reorders quick links.
Future<void> reorder(List<String> orderedIds) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final datasource = ref.read(quickLinksDatasourceProvider);
await datasource.reorderQuickLinks(orderedIds);
ref.invalidate(quickLinksProvider);
});
}
}
/// Default seed data for quick links (used when API returns empty).
List<QuickLink> getDefaultQuickLinks() {
return [
// Home category
const QuickLink(
id: 'jellyfin',
name: 'Jellyfin',
url: 'https://jellyfin.schweitz.net',
iconName: 'movie',
category: 'Home',
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'webui',
name: 'Open WebUI',
url: 'https://webui.schweitz.net',
iconName: 'chat',
category: 'Home',
type: QuickLinkType.iframe,
sortOrder: 1,
),
const QuickLink(
id: 'searxng',
name: 'SearXNG',
url: 'https://search.schweitz.net',
iconName: 'search',
category: 'Home',
type: QuickLinkType.newTab,
sortOrder: 2,
),
// AMP category
const QuickLink(
id: 'amp',
name: 'AMP Home',
url: 'https://amp.schweitz.net',
iconName: 'videogame_asset',
category: 'AMP',
type: QuickLinkType.newTab,
sortOrder: 0,
),
// Coding category
const QuickLink(
id: 'gitea',
name: 'Gitea',
url: 'https://git.schweitz.net',
iconName: 'code',
category: 'Coding',
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'cloud-ide',
name: 'Cloud IDE',
url: 'https://code.schweitz.net',
iconName: 'terminal',
category: 'Coding',
type: QuickLinkType.newTab,
sortOrder: 1,
),
// Infrastructure category
const QuickLink(
id: 'netdata',
name: 'Netdata',
url: 'https://netdata.schweitz.net',
iconName: 'monitoring',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 0,
),
const QuickLink(
id: 'portainer',
name: 'Portainer',
url: 'https://portainer.schweitz.net',
iconName: 'dns',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 1,
),
const QuickLink(
id: 'npm',
name: 'Proxy Manager',
url: 'https://npm.schweitz.net',
iconName: 'public',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 2,
),
const QuickLink(
id: 'core-api',
name: 'Core API',
url: 'https://api.schweitz.net/docs',
iconName: 'api',
category: 'Infrastructure',
type: QuickLinkType.iframe,
sortOrder: 3,
),
];
}