From 0dbc2618ab262f444c445be511f8c5ded3f71256 Mon Sep 17 00:00:00 2001 From: pavanakarthik12 Date: Mon, 18 May 2026 16:01:58 +0530 Subject: [PATCH 1/8] phase 1 established --- .env.example | 6 + .vscode/settings.json | 8 + README.md | 157 +++++++++++ index.html | 18 ++ package.json | 36 +++ postcss.config.cjs | 6 + reference_files/app_notification.dart | 81 ++++++ reference_files/firestore_repository.dart | 249 ++++++++++++++++++ reference_files/home_mock_data.dart | 303 ++++++++++++++++++++++ reference_files/news_article_model.dart | 159 ++++++++++++ reference_files/user_model.dart | 116 +++++++++ src/app/App.tsx | 14 + src/app/ErrorBoundary.tsx | 35 +++ src/app/LoadingScreen.tsx | 7 + src/app/providers/AppProviders.tsx | 30 +++ src/app/providers/AuthProvider.tsx | 55 ++++ src/app/providers/ThemeProvider.tsx | 50 ++++ src/app/providers/ToastProvider.tsx | 70 +++++ src/components/common/CommandPalette.tsx | 33 +++ src/components/common/FilterBar.tsx | 13 + src/components/common/GlobalSearch.tsx | 16 ++ src/components/common/Modal.tsx | 32 +++ src/components/common/Pagination.tsx | 23 ++ src/components/common/ThemeToggle.tsx | 14 + src/components/layout/Sidebar.tsx | 53 ++++ src/components/layout/Topbar.tsx | 40 +++ src/components/ui/badge.tsx | 14 + src/components/ui/button.tsx | 14 + src/components/ui/card.tsx | 6 + src/components/ui/input.tsx | 14 + src/components/ui/skeleton.tsx | 5 + src/components/ui/table.tsx | 10 + src/components/ui/textarea.tsx | 14 + src/constants/roles.ts | 4 + src/constants/routes.ts | 12 + src/firebase/client.ts | 18 ++ src/hooks/useAuth.ts | 5 + src/hooks/useTheme.ts | 5 + src/hooks/useToast.ts | 5 + src/layouts/AdminLayout.tsx | 23 ++ src/lib/env.ts | 25 ++ src/lib/utils.ts | 6 + src/main.tsx | 17 ++ src/modules/dashboard/StatCard.tsx | 12 + src/pages/Articles.tsx | 42 +++ src/pages/Dashboard.tsx | 75 ++++++ src/pages/HomeModules.tsx | 15 ++ src/pages/Login.tsx | 62 +++++ src/pages/Moderation.tsx | 15 ++ src/pages/NotFound.tsx | 10 + src/pages/Notifications.tsx | 15 ++ src/pages/Settings.tsx | 15 ++ src/pages/Sources.tsx | 15 ++ src/pages/Topics.tsx | 15 ++ src/pages/Users.tsx | 39 +++ src/routes/ProtectedRoute.tsx | 30 +++ src/routes/index.tsx | 44 ++++ src/services/firestore.ts | 25 ++ src/store/uiStore.ts | 19 ++ src/styles/globals.css | 57 ++++ src/types/articles.ts | 35 +++ src/types/comments.ts | 15 ++ src/types/home.ts | 66 +++++ src/types/index.ts | 9 + src/types/notifications.ts | 17 ++ src/types/sources.ts | 14 + src/types/topics.ts | 12 + src/types/users.ts | 24 ++ src/utils/format.ts | 3 + src/utils/guards.ts | 6 + tailwind.config.cjs | 35 +++ tsconfig.json | 24 ++ tsconfig.node.json | 10 + vite.config.ts | 15 ++ 74 files changed, 2611 insertions(+) create mode 100644 .env.example create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 index.html create mode 100644 package.json create mode 100644 postcss.config.cjs create mode 100644 reference_files/app_notification.dart create mode 100644 reference_files/firestore_repository.dart create mode 100644 reference_files/home_mock_data.dart create mode 100644 reference_files/news_article_model.dart create mode 100644 reference_files/user_model.dart create mode 100644 src/app/App.tsx create mode 100644 src/app/ErrorBoundary.tsx create mode 100644 src/app/LoadingScreen.tsx create mode 100644 src/app/providers/AppProviders.tsx create mode 100644 src/app/providers/AuthProvider.tsx create mode 100644 src/app/providers/ThemeProvider.tsx create mode 100644 src/app/providers/ToastProvider.tsx create mode 100644 src/components/common/CommandPalette.tsx create mode 100644 src/components/common/FilterBar.tsx create mode 100644 src/components/common/GlobalSearch.tsx create mode 100644 src/components/common/Modal.tsx create mode 100644 src/components/common/Pagination.tsx create mode 100644 src/components/common/ThemeToggle.tsx create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/layout/Topbar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/constants/roles.ts create mode 100644 src/constants/routes.ts create mode 100644 src/firebase/client.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/hooks/useTheme.ts create mode 100644 src/hooks/useToast.ts create mode 100644 src/layouts/AdminLayout.tsx create mode 100644 src/lib/env.ts create mode 100644 src/lib/utils.ts create mode 100644 src/main.tsx create mode 100644 src/modules/dashboard/StatCard.tsx create mode 100644 src/pages/Articles.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/HomeModules.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/pages/Moderation.tsx create mode 100644 src/pages/NotFound.tsx create mode 100644 src/pages/Notifications.tsx create mode 100644 src/pages/Settings.tsx create mode 100644 src/pages/Sources.tsx create mode 100644 src/pages/Topics.tsx create mode 100644 src/pages/Users.tsx create mode 100644 src/routes/ProtectedRoute.tsx create mode 100644 src/routes/index.tsx create mode 100644 src/services/firestore.ts create mode 100644 src/store/uiStore.ts create mode 100644 src/styles/globals.css create mode 100644 src/types/articles.ts create mode 100644 src/types/comments.ts create mode 100644 src/types/home.ts create mode 100644 src/types/index.ts create mode 100644 src/types/notifications.ts create mode 100644 src/types/sources.ts create mode 100644 src/types/topics.ts create mode 100644 src/types/users.ts create mode 100644 src/utils/format.ts create mode 100644 src/utils/guards.ts create mode 100644 tailwind.config.cjs create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b752be5 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID=startupsindia-mediaplatform +VITE_FIREBASE_STORAGE_BUCKET= +VITE_FIREBASE_MESSAGING_SENDER_ID= +VITE_FIREBASE_APP_ID= diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..036ab0f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "dart.analysisExcludedFolders": [ + "reference_files" + ], + "search.exclude": { + "reference_files": true + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4987fb --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# Admin Panel Handoff + +This folder contains reference files from the Flutter app for building a separate admin web project against the same Firebase project. + +The admin panel should not import these Dart files directly unless it is also a Dart/Flutter project. For a React, Next.js, or other web admin project, use them as the source of truth for Firestore collection names, document fields, and expected app behavior. + +## Firebase Project + +- Project ID: `startupsindia-mediaplatform` +- Auth domain: `startupsindia-mediaplatform.firebaseapp.com` +- Storage bucket: `startupsindia-mediaplatform.firebasestorage.app` + +Use the same Firebase project in the admin web app. + +## Reference Files + +- `reference_files/firestore_repository.dart` + - Shows current Firestore collection names and operations. + - Current collections: `users`, `articles`, `user_topics`, `users/{uid}/notifications`. + - Includes article create/read/search, like/bookmark toggles, user profile save, topic follow/unfollow, and Cloudinary image upload. + +- `reference_files/news_article_model.dart` + - Source of truth for current `articles/{articleId}` document fields. + +- `reference_files/user_model.dart` + - Source of truth for current `users/{uid}` document fields. + +- `reference_files/app_notification.dart` + - Source of truth for `users/{uid}/notifications/{notificationId}` fields. + +- `reference_files/home_mock_data.dart` + - Shows the home modules that should eventually become admin-managed collections: featured stories, events, courses, communities, leaderboard, and funding cards. + +## Current Firestore Schemas + +### `articles/{articleId}` + +```ts +{ + authorId: string, + category: string, + headline: string, + sourceName: string, + sourceId: string, + sourceLogoAsset: string, + thumbnailAsset: string, + timeAgo: string, + body: string, + likesCount: number, + commentsCount: number, + isSourceFollowing: boolean, + isBookmarked: boolean, + isLiked: boolean, + isTrending: boolean, + likedBy: string[], + bookmarkedBy: string[], + createdAt: Timestamp, + updatedAt: Timestamp +} +``` + +Admin panel should add moderation/status fields: + +```ts +{ + status: "draft" | "pending" | "published" | "rejected" | "archived", + publishedAt: Timestamp | null, + reviewedBy: string | null, + reviewedAt: Timestamp | null, + rejectionReason: string +} +``` + +### `users/{uid}` + +```ts +{ + uid: string, + username: string, + fullName: string, + email: string, + phone: string, + displayName: string, + bio: string, + avatarUrl: string, + websiteUrl: string, + followersCount: number, + followingCount: number, + newsCount: number, + role: string, + interests: string[], + onboardingCompleted: boolean, + fcmTokens: string[], + updatedAt: Timestamp +} +``` + +Admin panel should add admin/account fields: + +```ts +{ + accountStatus: "active" | "suspended" | "deleted", + adminRole: "user" | "author" | "moderator" | "admin", + isVerified: boolean, + createdAt: Timestamp, + lastLoginAt: Timestamp +} +``` + +### `user_topics/{uid}` + +```ts +{ + topics: string[], + updatedAt: Timestamp +} +``` + +### `users/{uid}/notifications/{notificationId}` + +```ts +{ + type: "news" | "follow" | "interaction", + title: string, + subtitle: string, + createdAt: Timestamp, + avatarLabel: string, + isRead: boolean, + payload: string | null +} +``` + +## Admin Modules To Build + +1. Admin auth and access guard +2. Articles CMS +3. Article moderation queue +4. User management +5. Topics/categories management +6. Sources/authors management +7. Notification campaigns +8. Comments moderation +9. Home modules CMS: + - Featured stories + - Funding opportunities + - Events + - Courses + - Communities + - Startup leaderboard + +## Security Notes + +- Do not trust client-side checks for admin access. +- Prefer Firebase Auth custom claims like `admin: true`. +- Alternatively, use a locked `admin_users/{uid}` collection. +- Sending push notifications must happen through Firebase Admin SDK, Cloud Functions, or a server route. Do not send FCM from the browser client SDK. + diff --git a/index.html b/index.html new file mode 100644 index 0000000..284f247 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + Startups India Admin + + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fe95f7 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "startupsindia-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^3.6.0", + "@tanstack/react-query": "^5.59.0", + "clsx": "^2.1.1", + "firebase": "^10.13.0", + "framer-motion": "^11.3.30", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "react-router-dom": "^6.26.2", + "recharts": "^2.12.7", + "tailwind-merge": "^2.5.2", + "zod": "^3.23.8", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@types/react": "^18.3.4", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "vite": "^5.4.1" + } +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/reference_files/app_notification.dart b/reference_files/app_notification.dart new file mode 100644 index 0000000..5ef7854 --- /dev/null +++ b/reference_files/app_notification.dart @@ -0,0 +1,81 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +enum NotificationType { news, follow, interaction } + +class AppNotification { + final String id; + final NotificationType type; + final String title; + final String subtitle; + final DateTime createdAt; + final String avatarLabel; + final bool isRead; + final String? payload; // E.g., article ID or user ID + + const AppNotification({ + required this.id, + required this.type, + required this.title, + required this.subtitle, + required this.createdAt, + required this.avatarLabel, + this.isRead = false, + this.payload, + }); + + factory AppNotification.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map? ?? {}; + + // Parse the notification type safely + final typeString = data['type'] as String? ?? 'news'; + final type = NotificationType.values.firstWhere( + (e) => e.name == typeString, + orElse: () => NotificationType.news, + ); + + return AppNotification( + id: doc.id, + type: type, + title: data['title'] as String? ?? 'Notification', + subtitle: data['subtitle'] as String? ?? '', + createdAt: (data['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + avatarLabel: data['avatarLabel'] as String? ?? 'N', + isRead: data['isRead'] as bool? ?? false, + payload: data['payload'] as String?, + ); + } + + Map toFirestore() { + return { + 'type': type.name, + 'title': title, + 'subtitle': subtitle, + 'createdAt': Timestamp.fromDate(createdAt), + 'avatarLabel': avatarLabel, + 'isRead': isRead, + 'payload': payload, + }; + } + + AppNotification copyWith({ + String? id, + NotificationType? type, + String? title, + String? subtitle, + DateTime? createdAt, + String? avatarLabel, + bool? isRead, + String? payload, + }) { + return AppNotification( + id: id ?? this.id, + type: type ?? this.type, + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + createdAt: createdAt ?? this.createdAt, + avatarLabel: avatarLabel ?? this.avatarLabel, + isRead: isRead ?? this.isRead, + payload: payload ?? this.payload, + ); + } +} diff --git a/reference_files/firestore_repository.dart b/reference_files/firestore_repository.dart new file mode 100644 index 0000000..5ea7208 --- /dev/null +++ b/reference_files/firestore_repository.dart @@ -0,0 +1,249 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloudinary_public/cloudinary_public.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/news_article_model.dart'; +import '../models/user_model.dart'; +import '../providers/firebase_providers.dart'; + +class FirestoreRepository { + FirestoreRepository(this._firestore); + + final FirebaseFirestore _firestore; + + CollectionReference> get _users => + _firestore.collection('users'); + + CollectionReference> get _articles => + _firestore.collection('articles'); + + CollectionReference> get _userTopics => + _firestore.collection('user_topics'); + + Future saveUser(UserModel user) { + return _users + .doc(user.uid) + .set(user.toFirestore(), SetOptions(merge: true)); + } + + /// Persists the role + interests chosen during onboarding and marks the + /// account as fully onboarded. Uses merge so existing profile fields are + /// never overwritten. + Future saveUserOnboarding({ + required String uid, + required String role, + required List interests, + }) { + return _users.doc(uid).set( + { + 'role': role, + 'interests': interests, + 'onboardingCompleted': true, + 'updatedAt': FieldValue.serverTimestamp(), + }, + SetOptions(merge: true), + ); + } + + Future getUserById(String uid) async { + final doc = await _users.doc(uid).get(); + if (!doc.exists) return null; + return UserModel.fromFirestore(doc); + } + + Stream> watchArticles() { + return _articles + .orderBy('updatedAt', descending: true) + .snapshots() + .map( + (snapshot) => snapshot.docs + .map(NewsArticleModel.fromFirestore) + .toList(growable: false), + ); + } + + Stream> getLatestNews() { + return _articles + .orderBy('createdAt', descending: true) + .snapshots() + .map( + (snapshot) => snapshot.docs + .map(NewsArticleModel.fromFirestore) + .toList(growable: false), + ); + } + + Stream> getTrendingNews() { + return getLatestNews().map( + (items) => + items.where((article) => article.isTrending).toList(growable: false), + ); + } + + Future getArticleById(String articleId) async { + if (articleId.trim().isEmpty) return null; + + final doc = await _articles.doc(articleId).get(); + if (!doc.exists) return null; + return NewsArticleModel.fromFirestore(doc); + } + + Stream> getArticlesByAuthor(String authorId) { + if (authorId.trim().isEmpty) return Stream.value([]); + + return getLatestNews().map( + (items) => items + .where((article) => article.authorId == authorId) + .toList(growable: false), + ); + } + + Stream> getBookmarkedArticles(String userId) { + if (userId.trim().isEmpty) return Stream.value([]); + + return getLatestNews().map( + (items) => items + .where((article) => article.bookmarkedBy.contains(userId)) + .map((article) => article.copyWith(isBookmarked: true)) + .toList(growable: false), + ); + } + + Stream> getLikedArticles(String userId) { + if (userId.trim().isEmpty) return Stream.value([]); + + return getLatestNews().map( + (items) => items + .where((article) => article.likedBy.contains(userId)) + .map((article) => article.copyWith(isLiked: true)) + .toList(growable: false), + ); + } + + Future> searchArticles(String query) async { + final normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.isEmpty) { + return []; + } + + final snapshot = await _articles + .orderBy('createdAt', descending: true) + .limit(100) + .get(); + + final articles = snapshot.docs + .map(NewsArticleModel.fromFirestore) + .toList(growable: false); + + return articles + .where((article) { + final headline = article.headline.toLowerCase(); + final sourceName = article.sourceName.toLowerCase(); + final category = article.category.toLowerCase(); + return headline.contains(normalizedQuery) || + sourceName.contains(normalizedQuery) || + category.contains(normalizedQuery); + }) + .toList(growable: false); + } + + Future saveArticle(NewsArticleModel article) { + return _articles + .doc(article.id) + .set(article.toFirestore(), SetOptions(merge: true)); + } + + Future uploadImage(String imagePath) async { + final cloudinary = CloudinaryPublic( + 'dmrp1d1tv', + 'startups india upload preset', + cache: false, + ); + + final response = await cloudinary.uploadFile( + CloudinaryFile.fromFile(imagePath), + ); + + return response.secureUrl; + } + + Future createArticle(NewsArticleModel article) async { + final docId = article.id.trim().isEmpty ? _articles.doc().id : article.id; + await _articles + .doc(docId) + .set(article.toFirestore(), SetOptions(merge: true)); + } + + // ── user_topics ────────────────────────────────────────────────────────── + + Stream> watchUserTopics(String uid) { + return _userTopics.doc(uid).snapshots().map( + (doc) => List.from(doc.data()?['topics'] as List? ?? []), + ); + } + + Future followTopic(String uid, String topic) { + return _userTopics.doc(uid).set({ + 'topics': FieldValue.arrayUnion([topic]), + 'updatedAt': FieldValue.serverTimestamp(), + }, SetOptions(merge: true)); + } + + Future unfollowTopic(String uid, String topic) { + return _userTopics.doc(uid).set({ + 'topics': FieldValue.arrayRemove([topic]), + 'updatedAt': FieldValue.serverTimestamp(), + }, SetOptions(merge: true)); + } + + // ── likes ───────────────────────────────────────────────────────────────── + + Future toggleLike(String articleId, String userId) async { + final docRef = _articles.doc(articleId); + + await _firestore.runTransaction((transaction) async { + final doc = await transaction.get(docRef); + if (!doc.exists) return; + + final data = doc.data() ?? {}; + final likedBy = List.from(data['likedBy'] as List? ?? []); + final alreadyLiked = likedBy.contains(userId); + + if (alreadyLiked) { + likedBy.remove(userId); + } else { + likedBy.add(userId); + } + + transaction.update(docRef, { + 'likedBy': likedBy, + 'likesCount': likedBy.length, + 'updatedAt': FieldValue.serverTimestamp(), + }); + }); + } + + Future toggleBookmark(String articleId, String userId) async { + final doc = await _articles.doc(articleId).get(); + if (!doc.exists) return; + + final data = doc.data() ?? {}; + final bookmarkedBy = List.from(data['bookmarkedBy'] as List? ?? []); + if (bookmarkedBy.contains(userId)) { + // Remove bookmark + await _articles.doc(articleId).update({ + 'bookmarkedBy': FieldValue.arrayRemove([userId]), + }); + } else { + // Add bookmark + await _articles.doc(articleId).update({ + 'bookmarkedBy': FieldValue.arrayUnion([userId]), + }); + } + } +} + +final firestoreRepositoryProvider = Provider((ref) { + final firestore = ref.watch(firebaseFirestoreProvider); + return FirestoreRepository(firestore); +}); diff --git a/reference_files/home_mock_data.dart b/reference_files/home_mock_data.dart new file mode 100644 index 0000000..a6fa5ca --- /dev/null +++ b/reference_files/home_mock_data.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; + +class HomeFeaturedStory { + final String badge; + final String headline; + final String highlightLine; + final String subtitle; + final Color gradientStart; + final Color gradientEnd; + + const HomeFeaturedStory({ + required this.badge, + required this.headline, + required this.highlightLine, + required this.subtitle, + required this.gradientStart, + required this.gradientEnd, + }); +} + +class HomeEvent { + final String day; + final String month; + final String title; + final String location; + final String attendees; + + const HomeEvent({ + required this.day, + required this.month, + required this.title, + required this.location, + required this.attendees, + }); +} + +class HomeCourse { + final String category; + final Color categoryColor; + final String title; + final String duration; + + const HomeCourse({ + required this.category, + required this.categoryColor, + required this.title, + required this.duration, + }); +} + +class HomeCommunity { + final String name; + final String memberCount; + final Color color; + final String initial; + + const HomeCommunity({ + required this.name, + required this.memberCount, + required this.color, + required this.initial, + }); +} + +class HomeLeaderEntry { + final int rank; + final String name; + final String sector; + final String valuation; + final String growth; + final Color color; + + const HomeLeaderEntry({ + required this.rank, + required this.name, + required this.sector, + required this.valuation, + required this.growth, + required this.color, + }); +} + +class HomeFundingCard { + final String company; + final String stage; + final String amount; + final String sector; + final Color color; + final String initial; + + const HomeFundingCard({ + required this.company, + required this.stage, + required this.amount, + required this.sector, + required this.color, + required this.initial, + }); +} + +class HomeMockData { + static const List featured = [ + HomeFeaturedStory( + badge: 'TOP STORY', + headline: 'Skyroot Aerospace\nRaises ₹125 Cr', + highlightLine: 'in Series B Round', + subtitle: 'Fueling the future of space tech in India.', + gradientStart: Color(0xFF1A0A2E), + gradientEnd: Color(0xFF16213E), + ), + HomeFeaturedStory( + badge: 'FUNDING', + headline: 'Zepto Hits\n\$1.4B Valuation', + highlightLine: 'in Just 3 Years', + subtitle: 'From a college project to India\'s fastest-growing startup.', + gradientStart: Color(0xFF0A1628), + gradientEnd: Color(0xFF0D2137), + ), + HomeFeaturedStory( + badge: 'SERIES F', + headline: 'CRED Secures\n\$80M Funding', + highlightLine: 'to build financial super app', + subtitle: 'Kunal Shah\'s fintech vision hits new heights.', + gradientStart: Color(0xFF1C0A0A), + gradientEnd: Color(0xFF2D1010), + ), + ]; + + static const List events = [ + HomeEvent( + day: '15', + month: 'JUN', + title: 'India Startup Summit 2025', + location: 'Bengaluru, Karnataka', + attendees: '2.1K attending', + ), + HomeEvent( + day: '22', + month: 'JUN', + title: 'Fintech Innovation Conference', + location: 'Mumbai, Maharashtra', + attendees: '890 attending', + ), + HomeEvent( + day: '28', + month: 'JUN', + title: 'Women Entrepreneurs Forum', + location: 'New Delhi', + attendees: '1.4K attending', + ), + HomeEvent( + day: '05', + month: 'JUL', + title: 'AI & Deep Tech Meetup', + location: 'Hyderabad, Telangana', + attendees: '620 attending', + ), + ]; + + static const List courses = [ + HomeCourse( + category: 'FUNDRAISING', + categoryColor: Color(0xFF00BA88), + title: 'How to Raise Funds for Your Startup', + duration: '6:50', + ), + HomeCourse( + category: 'GROWTH', + categoryColor: Color(0xFFE8341C), + title: '10 Growth Strategies Every Startup Should Know', + duration: '7:15', + ), + HomeCourse( + category: 'MARKETING', + categoryColor: Color(0xFF6C5CE7), + title: 'Startup Marketing on a Budget That Works', + duration: '6:30', + ), + HomeCourse( + category: 'BUILD', + categoryColor: Color(0xFFF4B740), + title: 'Building a Thought Leadership Brand', + duration: '5:45', + ), + HomeCourse( + category: 'PRODUCT', + categoryColor: Color(0xFF0984E3), + title: 'Product-Market Fit: A Founder\'s Playbook', + duration: '8:20', + ), + ]; + + static const List communities = [ + HomeCommunity( + name: 'Founders Circle', + memberCount: '12.4K members', + color: Color(0xFFE8341C), + initial: 'F', + ), + HomeCommunity( + name: 'Tech Builders India', + memberCount: '8.2K members', + color: Color(0xFF0984E3), + initial: 'T', + ), + HomeCommunity( + name: 'Women in Startups', + memberCount: '5.7K members', + color: Color(0xFF6C5CE7), + initial: 'W', + ), + HomeCommunity( + name: 'Angel Investors Network', + memberCount: '3.1K members', + color: Color(0xFF00BA88), + initial: 'A', + ), + HomeCommunity( + name: 'Early Stage Founders', + memberCount: '9.8K members', + color: Color(0xFFF4B740), + initial: 'E', + ), + ]; + + static const List leaderboard = [ + HomeLeaderEntry( + rank: 1, + name: 'Zepto', + sector: 'Quick Commerce', + valuation: '\$1.4B', + growth: '+142%', + color: Color(0xFF6C5CE7), + ), + HomeLeaderEntry( + rank: 2, + name: 'Razorpay', + sector: 'Fintech', + valuation: '\$7.5B', + growth: '+89%', + color: Color(0xFF0984E3), + ), + HomeLeaderEntry( + rank: 3, + name: 'Meesho', + sector: 'Social Commerce', + valuation: '\$4.9B', + growth: '+67%', + color: Color(0xFFE8341C), + ), + HomeLeaderEntry( + rank: 4, + name: 'CRED', + sector: 'Fintech', + valuation: '\$6.4B', + growth: '+45%', + color: Color(0xFF00BA88), + ), + HomeLeaderEntry( + rank: 5, + name: 'boAt', + sector: 'Consumer Tech', + valuation: '\$1.2B', + growth: '+38%', + color: Color(0xFFF4B740), + ), + ]; + + static const List funding = [ + HomeFundingCard( + company: 'IndiaStar Energy', + stage: 'Series A', + amount: '\$12M', + sector: 'Clean Energy', + color: Color(0xFF00BA88), + initial: 'I', + ), + HomeFundingCard( + company: 'EduVerse', + stage: 'Seed Round', + amount: '\$3.5M', + sector: 'EdTech', + color: Color(0xFF6C5CE7), + initial: 'E', + ), + HomeFundingCard( + company: 'HealthFirst AI', + stage: 'Series B', + amount: '\$25M', + sector: 'HealthTech', + color: Color(0xFF0984E3), + initial: 'H', + ), + HomeFundingCard( + company: 'AgriNext', + stage: 'Seed Round', + amount: '\$2M', + sector: 'AgriTech', + color: Color(0xFFF4B740), + initial: 'A', + ), + ]; +} diff --git a/reference_files/news_article_model.dart b/reference_files/news_article_model.dart new file mode 100644 index 0000000..7fc966c --- /dev/null +++ b/reference_files/news_article_model.dart @@ -0,0 +1,159 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class NewsArticleModel { + final String id; + final DateTime? createdAt; + final String authorId; + final String category; + final String headline; + final String sourceName; + final String sourceId; + final String sourceLogoAsset; + final String thumbnailAsset; + final String timeAgo; + final String body; + final int likesCount; + final int commentsCount; + final bool isSourceFollowing; + final bool isBookmarked; + final bool isLiked; + final bool isTrending; + final List likedBy; + final List bookmarkedBy; + + const NewsArticleModel({ + required this.id, + this.createdAt, + this.authorId = '', + required this.category, + required this.headline, + required this.sourceName, + this.sourceId = '', + required this.sourceLogoAsset, + required this.thumbnailAsset, + required this.timeAgo, + this.body = '', + this.likesCount = 0, + this.commentsCount = 0, + this.isSourceFollowing = false, + this.isBookmarked = false, + this.isLiked = false, + this.isTrending = false, + this.likedBy = const [], + this.bookmarkedBy = const [], + }); + + NewsArticleModel copyWith({ + String? id, + DateTime? createdAt, + String? authorId, + String? category, + String? headline, + String? sourceName, + String? sourceId, + String? sourceLogoAsset, + String? thumbnailAsset, + String? timeAgo, + String? body, + int? likesCount, + int? commentsCount, + bool? isSourceFollowing, + bool? isBookmarked, + bool? isLiked, + bool? isTrending, + List? likedBy, + List? bookmarkedBy, + }) { + return NewsArticleModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + authorId: authorId ?? this.authorId, + category: category ?? this.category, + headline: headline ?? this.headline, + sourceName: sourceName ?? this.sourceName, + sourceId: sourceId ?? this.sourceId, + sourceLogoAsset: sourceLogoAsset ?? this.sourceLogoAsset, + thumbnailAsset: thumbnailAsset ?? this.thumbnailAsset, + timeAgo: timeAgo ?? this.timeAgo, + body: body ?? this.body, + likesCount: likesCount ?? this.likesCount, + commentsCount: commentsCount ?? this.commentsCount, + isSourceFollowing: isSourceFollowing ?? this.isSourceFollowing, + isBookmarked: isBookmarked ?? this.isBookmarked, + isLiked: isLiked ?? this.isLiked, + isTrending: isTrending ?? this.isTrending, + likedBy: likedBy ?? this.likedBy, + bookmarkedBy: bookmarkedBy ?? this.bookmarkedBy, + ); + } + + factory NewsArticleModel.fromMap(String id, Map map) { + final createdAtValue = map['createdAt']; + DateTime? createdAt; + if (createdAtValue is Timestamp) { + createdAt = createdAtValue.toDate(); + } else if (createdAtValue is DateTime) { + createdAt = createdAtValue; + } + + return NewsArticleModel( + id: id, + createdAt: createdAt, + authorId: map['authorId'] as String? ?? '', + category: map['category'] as String? ?? '', + headline: map['headline'] as String? ?? '', + sourceName: map['sourceName'] as String? ?? '', + sourceId: map['sourceId'] as String? ?? '', + sourceLogoAsset: map['sourceLogoAsset'] as String? ?? '', + thumbnailAsset: map['thumbnailAsset'] as String? ?? '', + timeAgo: map['timeAgo'] as String? ?? '', + body: map['body'] as String? ?? '', + likesCount: (map['likesCount'] as num?)?.toInt() ?? 0, + commentsCount: (map['commentsCount'] as num?)?.toInt() ?? 0, + isSourceFollowing: map['isSourceFollowing'] as bool? ?? false, + isBookmarked: map['isBookmarked'] as bool? ?? false, + isLiked: map['isLiked'] as bool? ?? false, + isTrending: map['isTrending'] as bool? ?? false, + likedBy: List.from(map['likedBy'] as List? ?? []), + bookmarkedBy: List.from(map['bookmarkedBy'] as List? ?? []), + ); + } + + factory NewsArticleModel.fromFirestore( + DocumentSnapshot> doc, + ) { + return NewsArticleModel.fromMap(doc.id, doc.data() ?? {}); + } + + Map toMap() { + return { + 'authorId': authorId, + 'category': category, + 'headline': headline, + 'sourceName': sourceName, + 'sourceId': sourceId, + 'sourceLogoAsset': sourceLogoAsset, + 'thumbnailAsset': thumbnailAsset, + 'timeAgo': timeAgo, + 'body': body, + 'likesCount': likesCount, + 'commentsCount': commentsCount, + 'isSourceFollowing': isSourceFollowing, + 'isBookmarked': isBookmarked, + 'isLiked': isLiked, + 'isTrending': isTrending, + 'likedBy': likedBy, + 'bookmarkedBy': bookmarkedBy, + }; + } + + Map toFirestore() { + return { + ...toMap(), + 'createdAt': createdAt == null + ? FieldValue.serverTimestamp() + : Timestamp.fromDate(createdAt!), + 'updatedAt': FieldValue.serverTimestamp(), + }; + } +} diff --git a/reference_files/user_model.dart b/reference_files/user_model.dart new file mode 100644 index 0000000..bd2c217 --- /dev/null +++ b/reference_files/user_model.dart @@ -0,0 +1,116 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class UserModel { + final String uid; + final String username; + final String fullName; + final String email; + final String phone; + final String displayName; + final String bio; + final String avatarUrl; + final String websiteUrl; + final int followersCount; + final int followingCount; + final int newsCount; + // ── Onboarding ──────────────────────────────────────────────────────────── + final String role; // e.g. 'founder', 'student' + final List interests; // e.g. ['ai', 'saas'] + final bool onboardingCompleted; + + const UserModel({ + required this.uid, + this.username = '', + this.fullName = '', + this.email = '', + this.phone = '', + required this.displayName, + required this.bio, + required this.avatarUrl, + required this.websiteUrl, + required this.followersCount, + required this.followingCount, + required this.newsCount, + this.role = '', + this.interests = const [], + this.onboardingCompleted = false, + }); + + UserModel copyWith({ + String? uid, + String? username, + String? fullName, + String? email, + String? phone, + String? displayName, + String? bio, + String? avatarUrl, + String? websiteUrl, + int? followersCount, + int? followingCount, + int? newsCount, + String? role, + List? interests, + bool? onboardingCompleted, + }) { + return UserModel( + uid: uid ?? this.uid, + username: username ?? this.username, + fullName: fullName ?? this.fullName, + email: email ?? this.email, + phone: phone ?? this.phone, + displayName: displayName ?? this.displayName, + bio: bio ?? this.bio, + avatarUrl: avatarUrl ?? this.avatarUrl, + websiteUrl: websiteUrl ?? this.websiteUrl, + followersCount: followersCount ?? this.followersCount, + followingCount: followingCount ?? this.followingCount, + newsCount: newsCount ?? this.newsCount, + role: role ?? this.role, + interests: interests ?? this.interests, + onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, + ); + } + + factory UserModel.fromFirestore(DocumentSnapshot> doc) { + final data = doc.data() ?? {}; + return UserModel( + uid: doc.id, + username: data['username'] as String? ?? '', + fullName: data['fullName'] as String? ?? '', + email: data['email'] as String? ?? '', + phone: data['phone'] as String? ?? '', + displayName: data['displayName'] as String? ?? '', + bio: data['bio'] as String? ?? '', + avatarUrl: data['avatarUrl'] as String? ?? '', + websiteUrl: data['websiteUrl'] as String? ?? '', + followersCount: (data['followersCount'] as num?)?.toInt() ?? 0, + followingCount: (data['followingCount'] as num?)?.toInt() ?? 0, + newsCount: (data['newsCount'] as num?)?.toInt() ?? 0, + role: data['role'] as String? ?? '', + interests: List.from(data['interests'] as List? ?? []), + onboardingCompleted: data['onboardingCompleted'] as bool? ?? false, + ); + } + + Map toFirestore() { + return { + 'uid': uid, + 'username': username, + 'fullName': fullName, + 'email': email, + 'phone': phone, + 'displayName': displayName, + 'bio': bio, + 'avatarUrl': avatarUrl, + 'websiteUrl': websiteUrl, + 'followersCount': followersCount, + 'followingCount': followingCount, + 'newsCount': newsCount, + 'role': role, + 'interests': interests, + 'onboardingCompleted': onboardingCompleted, + 'updatedAt': FieldValue.serverTimestamp(), + }; + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..aa53fc7 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,14 @@ +import { Suspense } from "react"; +import { RouteRenderer } from "@/routes"; +import { LoadingScreen } from "@/app/LoadingScreen"; +import { ErrorBoundary } from "@/app/ErrorBoundary"; + +export function App() { + return ( + + }> + + + + ); +} diff --git a/src/app/ErrorBoundary.tsx b/src/app/ErrorBoundary.tsx new file mode 100644 index 0000000..0c1958a --- /dev/null +++ b/src/app/ErrorBoundary.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; +import { Component } from "react"; + +type ErrorBoundaryState = { + hasError: boolean; +}; + +type ErrorBoundaryProps = { + children: ReactNode; +}; + +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return ( +
+
+

Something went wrong

+

+ Please refresh or contact support if the issue persists. +

+
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/app/LoadingScreen.tsx b/src/app/LoadingScreen.tsx new file mode 100644 index 0000000..ec6c38e --- /dev/null +++ b/src/app/LoadingScreen.tsx @@ -0,0 +1,7 @@ +export function LoadingScreen() { + return ( +
+
+
+ ); +} diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..df70f5d --- /dev/null +++ b/src/app/providers/AppProviders.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "@/app/providers/ThemeProvider"; +import { AuthProvider } from "@/app/providers/AuthProvider"; +import { ToastProvider } from "@/app/providers/ToastProvider"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false + } + } +}); + +type AppProvidersProps = { + children: ReactNode; +}; + +export function AppProviders({ children }: AppProvidersProps) { + return ( + + + + {children} + + + + ); +} diff --git a/src/app/providers/AuthProvider.tsx b/src/app/providers/AuthProvider.tsx new file mode 100644 index 0000000..c1b3b0b --- /dev/null +++ b/src/app/providers/AuthProvider.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { onAuthStateChanged, getIdTokenResult } from "firebase/auth"; +import { auth } from "@/firebase/client"; +import type { AdminRole, AuthUser } from "@/types"; + +export type AuthContextValue = { + user: AuthUser | null; + role: AdminRole; + loading: boolean; +}; + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [role, setRole] = useState("user"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => { + if (!firebaseUser) { + setUser(null); + setRole("user"); + setLoading(false); + return; + } + + const tokenResult = await getIdTokenResult(firebaseUser, true); + const claimsRole = tokenResult.claims.role as AdminRole | undefined; + + setUser({ + uid: firebaseUser.uid, + email: firebaseUser.email ?? "", + displayName: firebaseUser.displayName ?? "" + }); + setRole(claimsRole ?? "user"); + setLoading(false); + }); + + return () => unsubscribe(); + }, []); + + const value = useMemo(() => ({ user, role, loading }), [user, role, loading]); + + return {children}; +} + +export function useAuthContext() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuthContext must be used within AuthProvider"); + } + return context; +} diff --git a/src/app/providers/ThemeProvider.tsx b/src/app/providers/ThemeProvider.tsx new file mode 100644 index 0000000..ce01745 --- /dev/null +++ b/src/app/providers/ThemeProvider.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; + +export type ThemeMode = "light" | "dark"; + +type ThemeContextValue = { + theme: ThemeMode; + setTheme: (theme: ThemeMode) => void; + toggleTheme: () => void; +}; + +const ThemeContext = createContext(undefined); + +const storageKey = "si-admin-theme"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState("light"); + + useEffect(() => { + const stored = localStorage.getItem(storageKey); + const preferred = stored === "dark" ? "dark" : "light"; + setThemeState(preferred); + }, []); + + useEffect(() => { + const root = document.documentElement; + root.classList.toggle("dark", theme === "dark"); + localStorage.setItem(storageKey, theme); + }, [theme]); + + const setTheme = useCallback((mode: ThemeMode) => { + setThemeState(mode); + }, []); + + const toggleTheme = useCallback(() => { + setThemeState((prev) => (prev === "dark" ? "light" : "dark")); + }, []); + + const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]); + + return {children}; +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within ThemeProvider"); + } + return context; +} diff --git a/src/app/providers/ToastProvider.tsx b/src/app/providers/ToastProvider.tsx new file mode 100644 index 0000000..237392d --- /dev/null +++ b/src/app/providers/ToastProvider.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; + +export type Toast = { + id: string; + title: string; + description?: string; + variant?: "default" | "danger"; +}; + +type ToastContextValue = { + toasts: Toast[]; + push: (toast: Omit) => void; + dismiss: (id: string) => void; +}; + +const ToastContext = createContext(undefined); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const push = useCallback((toast: Omit) => { + const id = crypto.randomUUID(); + setToasts((prev) => [...prev, { ...toast, id }]); + setTimeout(() => dismiss(id), 5000); + }, [dismiss]); + + const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+

{toast.title}

+ {toast.description && ( +

{toast.description}

+ )} + +
+ ))} +
+
+ ); +} + +export function useToastContext() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToastContext must be used within ToastProvider"); + } + return context; +} diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx new file mode 100644 index 0000000..27401ef --- /dev/null +++ b/src/components/common/CommandPalette.tsx @@ -0,0 +1,33 @@ +import { useUiStore } from "@/store/uiStore"; +import { Modal } from "@/components/common/Modal"; + +const commands = [ + "Create article", + "Send notification", + "View users", + "Open settings", + "Go to moderation" +]; + +export function CommandPalette() { + const { commandOpen, setCommandOpen } = useUiStore(); + + return ( + setCommandOpen(false)} title="Command Palette"> + +
+ {commands.map((command) => ( + + ))} +
+
+ ); +} diff --git a/src/components/common/FilterBar.tsx b/src/components/common/FilterBar.tsx new file mode 100644 index 0000000..d4cf375 --- /dev/null +++ b/src/components/common/FilterBar.tsx @@ -0,0 +1,13 @@ +import { Input } from "@/components/ui/input"; + +export function FilterBar({ placeholder }: { placeholder: string }) { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/src/components/common/GlobalSearch.tsx b/src/components/common/GlobalSearch.tsx new file mode 100644 index 0000000..e7223f3 --- /dev/null +++ b/src/components/common/GlobalSearch.tsx @@ -0,0 +1,16 @@ +import { useUiStore } from "@/store/uiStore"; +import { Modal } from "@/components/common/Modal"; + +export function GlobalSearch() { + const { searchOpen, setSearchOpen } = useUiStore(); + + return ( + setSearchOpen(false)} title="Global Search"> + +

Search results will appear here.

+
+ ); +} diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx new file mode 100644 index 0000000..bb2ecfd --- /dev/null +++ b/src/components/common/Modal.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export function Modal({ + open, + onClose, + title, + children, + className +}: { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; + className?: string; +}) { + if (!open) return null; + + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/src/components/common/Pagination.tsx b/src/components/common/Pagination.tsx new file mode 100644 index 0000000..0f2404c --- /dev/null +++ b/src/components/common/Pagination.tsx @@ -0,0 +1,23 @@ +export function Pagination({ page, total, onPageChange }: { page: number; total: number; onPageChange: (page: number) => void }) { + return ( +
+ + + Page {page} of {total} + + +
+ ); +} diff --git a/src/components/common/ThemeToggle.tsx b/src/components/common/ThemeToggle.tsx new file mode 100644 index 0000000..5a5ce84 --- /dev/null +++ b/src/components/common/ThemeToggle.tsx @@ -0,0 +1,14 @@ +import { useTheme } from "@/hooks/useTheme"; + +export function ThemeToggle() { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..e242167 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,53 @@ +import { NavLink } from "react-router-dom"; +import { useUiStore } from "@/store/uiStore"; +import { routes } from "@/constants/routes"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { label: "Dashboard", href: routes.dashboard }, + { label: "Articles", href: routes.articles }, + { label: "Users", href: routes.users }, + { label: "Topics", href: routes.topics }, + { label: "Sources", href: routes.sources }, + { label: "Notifications", href: routes.notifications }, + { label: "Moderation", href: routes.moderation }, + { label: "Home Modules", href: routes.homeModules }, + { label: "Settings", href: routes.settings } +]; + +export function Sidebar() { + const { sidebarCollapsed } = useUiStore(); + + return ( + + ); +} diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx new file mode 100644 index 0000000..7c4fbad --- /dev/null +++ b/src/components/layout/Topbar.tsx @@ -0,0 +1,40 @@ +import { useUiStore } from "@/store/uiStore"; +import { ThemeToggle } from "@/components/common/ThemeToggle"; +import { cn } from "@/lib/utils"; + +export function Topbar() { + const { toggleSidebar, setCommandOpen, setSearchOpen } = useUiStore(); + + return ( +
+
+ + +
+
+ + +
+ Admin +
+
+
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..6c82620 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,14 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +export function Badge({ className, ...props }: HTMLAttributes) { + return ( + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..dd44f78 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,14 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +export function Button({ className, ...props }: ButtonHTMLAttributes) { + return ( +