Browse Source

switch to tailwind

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
a8d8143be2
  1. 52
      frontend/src/App.css
  2. 5
      frontend/src/App.tsx
  3. 178
      frontend/src/components/ChannelManager/ChannelManager.css
  4. 63
      frontend/src/components/ChannelManager/ChannelManager.tsx
  5. 11
      frontend/src/components/ErrorBoundary.tsx
  6. 233
      frontend/src/components/Navbar/Navbar.css
  7. 9
      frontend/src/components/Navbar/Navbar.tsx
  8. 121
      frontend/src/components/SearchFilter/SearchFilter.css
  9. 25
      frontend/src/components/SearchFilter/SearchFilter.tsx
  10. 268
      frontend/src/components/TimeLimitManager/TimeLimitManager.css
  11. 77
      frontend/src/components/TimeLimitManager/TimeLimitManager.tsx
  12. 118
      frontend/src/components/VideoCard/VideoCard.css
  13. 35
      frontend/src/components/VideoCard/VideoCard.tsx
  14. 167
      frontend/src/components/VideoGrid/VideoGrid.css
  15. 44
      frontend/src/components/VideoGrid/VideoGrid.tsx
  16. 154
      frontend/src/components/VideoPlayer/VideoPlayer.css
  17. 41
      frontend/src/components/VideoPlayer/VideoPlayer.tsx
  18. 371
      frontend/src/components/WordGroupManager/WordGroupManager.css
  19. 89
      frontend/src/components/WordGroupManager/WordGroupManager.tsx
  20. 206
      frontend/src/pages/AdminPage.css
  21. 40
      frontend/src/pages/AdminPage.tsx
  22. 143
      frontend/src/pages/LandingPage.css
  23. 101
      frontend/src/pages/LoginPage.css
  24. 18
      frontend/src/pages/SpeechSoundsAdminPage.tsx
  25. 13
      frontend/src/pages/VideoApp.css
  26. 5
      frontend/src/pages/VideoApp.tsx
  27. 32
      frontend/src/pages/VideosAdminPage.tsx

52
frontend/src/App.css

@ -1,52 +0,0 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
}
/* Error container styling */
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
text-align: center;
}
.error-container h1 {
font-size: 24px;
margin-bottom: 16px;
color: var(--primary);
}
.error-container p {
font-size: 14px;
color: var(--muted-foreground);
margin-bottom: 24px;
}
.error-container button {
padding: 10px 20px;
background: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: 999px;
font-size: 14px;
cursor: pointer;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.error-container button:hover {
transform: translateY(-1px);
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.12);
}

5
frontend/src/App.tsx

@ -11,16 +11,15 @@ import { SpeechSoundsAdminPage } from './pages/SpeechSoundsAdminPage';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
import { APPS } from './config/apps'; import { APPS } from './config/apps';
import './globals.css'; import './globals.css';
import './App.css';
function App() { function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<div className="app"> <div className="min-h-screen flex flex-col">
<Navbar /> <Navbar />
<main className="main-content"> <main className="flex-1">
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
{/* Dynamically generate routes for enabled apps */} {/* Dynamically generate routes for enabled apps */}

178
frontend/src/components/ChannelManager/ChannelManager.css

@ -1,178 +0,0 @@
.channel-manager {
width: 100%;
padding: 24px;
background: var(--color-surface);
border-radius: 12px;
border: 1px solid rgba(212, 222, 239, 0.8);
}
.channel-manager h2 {
margin: 0 0 24px 0;
font-size: 24px;
font-weight: 500;
}
.add-channel-form {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.channel-input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.channel-input:focus {
outline: none;
border-color: #065fd4;
}
.add-button {
padding: 12px 24px;
background-color: #065fd4;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s;
}
.add-button:hover:not(:disabled) {
background-color: #0556c4;
}
.add-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
}
.alert-error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.alert-success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.empty-message {
text-align: center;
padding: 48px 24px;
color: #606060;
font-size: 14px;
}
.channels-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.channel-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background-color: #f9f9f9;
border-radius: 8px;
border: 1px solid #e5e5e5;
}
.channel-thumbnail {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.channel-info {
flex: 1;
min-width: 0;
}
.channel-name {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 500;
color: #030303;
}
.channel-stats {
margin: 0 0 4px 0;
font-size: 14px;
color: #606060;
}
.channel-meta {
margin: 0;
font-size: 12px;
color: #909090;
}
.channel-error {
margin: 0;
font-size: 12px;
color: #d00;
}
.remove-button {
padding: 8px 16px;
background-color: #fff;
color: #d00;
border: 1px solid #d00;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.remove-button:hover {
background-color: #d00;
color: white;
}
@media (max-width: 768px) {
.channel-manager {
padding: 16px;
}
.add-channel-form {
flex-direction: column;
}
.channel-item {
flex-direction: column;
align-items: flex-start;
}
.channel-thumbnail {
width: 60px;
height: 60px;
}
.remove-button {
align-self: flex-end;
}
}

63
frontend/src/components/ChannelManager/ChannelManager.tsx

@ -1,6 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useChannels } from '../../hooks/useChannels'; import { useChannels } from '../../hooks/useChannels';
import './ChannelManager.css';
export function ChannelManager() { export function ChannelManager() {
const { channels, loading, error, addChannel, removeChannel } = useChannels(); const { channels, loading, error, addChannel, removeChannel } = useChannels();
@ -48,59 +47,80 @@ export function ChannelManager() {
}; };
return ( return (
<div className="channel-manager"> <div className="w-full p-6 bg-card rounded-xl border border-border">
<h2>Channel Management</h2> <h2 className="m-0 mb-6 text-2xl font-medium text-foreground">Channel Management</h2>
<form onSubmit={handleAddChannel} className="add-channel-form"> <form onSubmit={handleAddChannel} className="flex gap-3 mb-6 md:flex-row flex-col">
<input <input
type="text" type="text"
placeholder="Enter channel ID, @handle, or YouTube URL..." placeholder="Enter channel ID, @handle, or YouTube URL..."
value={channelInput} value={channelInput}
onChange={(e) => setChannelInput(e.target.value)} onChange={(e) => setChannelInput(e.target.value)}
disabled={adding} disabled={adding}
className="channel-input" className="flex-1 px-4 py-3 border border-border rounded-md text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/> />
<button type="submit" disabled={adding || !channelInput.trim()} className="add-button"> <button
type="submit"
disabled={adding || !channelInput.trim()}
className="px-6 py-3 bg-primary text-primary-foreground border-none rounded-md text-sm font-medium cursor-pointer whitespace-nowrap transition-all hover:bg-primary/90 disabled:opacity-60 disabled:cursor-not-allowed"
>
{adding ? 'Adding...' : 'Add Channel'} {adding ? 'Adding...' : 'Add Channel'}
</button> </button>
</form> </form>
{addError && <div className="alert alert-error">{addError}</div>} {addError && (
{addSuccess && <div className="alert alert-success">{addSuccess}</div>} <div className="px-4 py-3 rounded-md mb-4 text-sm bg-red-50 text-red-800 border border-red-200">
{addError}
</div>
)}
{addSuccess && (
<div className="px-4 py-3 rounded-md mb-4 text-sm bg-green-50 text-green-800 border border-green-200">
{addSuccess}
</div>
)}
{loading && <p>Loading channels...</p>} {loading && <p className="text-foreground">Loading channels...</p>}
{error && <div className="alert alert-error">{error}</div>} {error && (
<div className="px-4 py-3 rounded-md mb-4 text-sm bg-red-50 text-red-800 border border-red-200">
{error}
</div>
)}
{!loading && channels.length === 0 && ( {!loading && channels.length === 0 && (
<p className="empty-message">No channels added yet. Add your first channel above!</p> <p className="text-center py-12 px-6 text-muted-foreground text-sm">
No channels added yet. Add your first channel above!
</p>
)} )}
{channels.length > 0 && ( {channels.length > 0 && (
<div className="channels-list"> <div className="flex flex-col gap-4">
{channels.map(channel => ( {channels.map(channel => (
<div key={channel.id} className="channel-item"> <div
key={channel.id}
className="flex items-center gap-4 p-4 bg-muted rounded-lg border border-border md:flex-row flex-col md:items-center items-start"
>
<img <img
src={channel.thumbnailUrl} src={channel.thumbnailUrl}
alt={channel.name} alt={channel.name}
className="channel-thumbnail" className="w-20 h-20 md:w-20 md:h-20 w-15 h-15 rounded-full object-cover flex-shrink-0"
/> />
<div className="channel-info"> <div className="flex-1 min-w-0">
<h3 className="channel-name">{channel.name}</h3> <h3 className="m-0 mb-1 text-base font-medium text-foreground">{channel.name}</h3>
<p className="channel-stats"> <p className="m-0 mb-1 text-sm text-muted-foreground">
{formatNumber(channel.subscriberCount)} subscribers {channel.videoCount} videos {formatNumber(channel.subscriberCount)} subscribers {channel.videoCount} videos
</p> </p>
{channel.lastFetchedAt && ( {channel.lastFetchedAt && (
<p className="channel-meta"> <p className="m-0 text-xs text-muted-foreground/70">
Last updated: {new Date(channel.lastFetchedAt).toLocaleString()} Last updated: {new Date(channel.lastFetchedAt).toLocaleString()}
</p> </p>
)} )}
{channel.fetchError && ( {channel.fetchError && (
<p className="channel-error">Error: {channel.fetchError}</p> <p className="m-0 text-xs text-red-600">Error: {channel.fetchError}</p>
)} )}
</div> </div>
<button <button
onClick={() => handleRemoveChannel(channel.id, channel.name)} onClick={() => handleRemoveChannel(channel.id, channel.name)}
className="remove-button" className="px-4 py-2 bg-card text-red-600 border border-red-600 rounded-md text-sm font-medium cursor-pointer whitespace-nowrap transition-all hover:bg-red-600 hover:text-white md:self-auto self-end"
> >
Remove Remove
</button> </button>
@ -111,6 +131,3 @@ export function ChannelManager() {
</div> </div>
); );
} }

11
frontend/src/components/ErrorBoundary.tsx

@ -26,10 +26,13 @@ export class ErrorBoundary extends React.Component<Props, State> {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="error-container"> <div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
<h1>Something went wrong</h1> <h1 className="text-2xl mb-4 text-primary">Something went wrong</h1>
<p>{this.state.error?.message}</p> <p className="text-sm text-muted-foreground mb-6">{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}> <button
onClick={() => window.location.reload()}
className="px-5 py-2.5 bg-primary text-primary-foreground border-none rounded-full text-sm cursor-pointer shadow-lg transition-all hover:-translate-y-0.5 hover:shadow-xl"
>
Reload Page Reload Page
</button> </button>
</div> </div>

233
frontend/src/components/Navbar/Navbar.css

@ -1,233 +0,0 @@
.navbar {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 10px 30px var(--color-shadow);
}
.navbar-container {
max-width: 1600px;
margin: 0 auto;
padding: 12px 0px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-logo {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--color-primary-dark);
font-size: 20px;
font-weight: 600;
}
.logo-icon {
font-size: 24px;
}
.logo-text {
font-family: 'YouTube Sans', 'Roboto', sans-serif;
}
.navbar-menu {
display: flex;
align-items: center;
gap: 24px;
}
.navbar-link {
text-decoration: none;
color: var(--color-text);
font-size: 14px;
font-weight: 500;
padding: 8px 12px;
border-radius: 999px;
transition: background-color 0.2s, color 0.2s;
}
.navbar-link:hover {
background-color: rgba(47, 128, 237, 0.12);
color: var(--color-primary-dark);
}
.navbar-user {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-username {
font-size: 14px;
color: var(--color-muted);
}
.navbar-button {
background: var(--color-primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 10px 20px rgba(32, 76, 130, 0.25);
}
.navbar-button:hover {
transform: translateY(-1px);
box-shadow: 0 12px 26px rgba(32, 76, 130, 0.32);
}
/* Search and Filters Section */
.navbar-filters {
background-color: var(--color-surface-muted);
border-top: 1px solid var(--color-border);
padding: 16px 24px;
}
.navbar-filters-container {
max-width: 1600px;
margin: 0 auto;
display: flex;
gap: 16px;
align-items: center;
}
.navbar-search-form {
flex: 1;
display: flex;
gap: 8px;
max-width: 500px;
}
.navbar-search-input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--color-border);
font-size: 14px;
background-color: var(--color-surface);
border-radius: 999px;
}
.navbar-search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 8px 22px rgba(47, 128, 237, 0.1);
}
.navbar-search-button {
padding: 10px 18px;
background: var(--color-primary);
border: none;
border-radius: 999px;
cursor: pointer;
font-size: 16px;
transition: transform 0.2s, box-shadow 0.2s;
color: white;
box-shadow: 0 10px 22px rgba(32, 76, 130, 0.25);
}
.navbar-search-button:hover {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(32, 76, 130, 0.32);
}
.navbar-filter-controls {
display: flex;
gap: 12px;
align-items: center;
}
.navbar-filter-select {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 14px;
background-color: var(--color-surface);
cursor: pointer;
color: var(--color-text);
}
.navbar-filter-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.18);
}
.navbar-clear-button {
padding: 8px 12px;
background-color: transparent;
border: 1px solid var(--color-border);
border-radius: 999px;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
transition: background-color 0.2s, color 0.2s;
color: var(--color-muted);
}
.navbar-clear-button:hover {
background-color: rgba(47, 128, 237, 0.08);
color: var(--color-text);
}
/* Mobile Responsive - Second Row Layout */
@media (max-width: 1024px) {
.navbar-filters-container {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.navbar-search-form {
max-width: 100%;
}
.navbar-filter-controls {
justify-content: space-between;
flex-wrap: wrap;
}
.navbar-filter-select {
flex: 1;
min-width: 120px;
}
}
@media (max-width: 768px) {
.navbar-container {
padding: 8px 16px;
}
.navbar-filters {
padding: 12px 16px;
}
.navbar-menu {
gap: 12px;
}
.logo-text {
display: none;
}
.navbar-username {
display: none;
}
.navbar-filter-controls {
gap: 8px;
}
.navbar-clear-button {
width: 100%;
}
}

9
frontend/src/components/Navbar/Navbar.tsx

@ -89,15 +89,6 @@ export function Navbar() {
Home Home
</Link> </Link>
{isAuthenticated && (
<Link
to="/admin"
className="text-sm font-semibold px-3 py-2 rounded-full bg-white text-foreground border-2 border-primary hover:bg-pink-50 transition-all active:scale-95"
>
Admin
</Link>
)}
{isAuthenticated ? ( {isAuthenticated ? (
<button <button
onClick={handleLogout} onClick={handleLogout}

121
frontend/src/components/SearchFilter/SearchFilter.css

@ -1,121 +0,0 @@
.search-filter {
background-color: var(--color-surface-muted);
border-bottom: 1px solid var(--color-border);
padding: 16px 24px;
}
.search-filter-container {
max-width: 1600px;
margin: 0 auto;
display: flex;
gap: 16px;
align-items: center;
}
.search-form {
flex: 1;
display: flex;
gap: 8px;
max-width: 500px;
}
.search-input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 14px;
background-color: var(--color-surface);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 8px 22px rgba(47, 128, 237, 0.1);
}
.search-button {
padding: 10px 18px;
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
border: none;
border-radius: 999px;
cursor: pointer;
font-size: 16px;
color: white;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 12px 24px rgba(32, 76, 130, 0.28);
}
.search-button:hover {
transform: translateY(-1px);
box-shadow: 0 16px 28px rgba(32, 76, 130, 0.32);
}
.filter-controls {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 14px;
background-color: var(--color-surface);
cursor: pointer;
color: var(--color-text);
}
.filter-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.18);
}
.clear-button {
padding: 8px 12px;
background-color: transparent;
border: 1px solid var(--color-border);
border-radius: 999px;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
transition: background-color 0.2s, color 0.2s;
color: var(--color-muted);
}
.clear-button:hover {
background-color: rgba(47, 128, 237, 0.08);
color: var(--color-text);
}
@media (max-width: 768px) {
.search-filter {
padding: 12px 16px;
}
.search-filter-container {
flex-direction: column;
align-items: stretch;
}
.search-form {
max-width: 100%;
}
.filter-controls {
flex-wrap: wrap;
}
.filter-select {
flex: 1;
}
.clear-button {
flex: 1;
}
}

25
frontend/src/components/SearchFilter/SearchFilter.tsx

@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import './SearchFilter.css';
interface SearchFilterProps { interface SearchFilterProps {
onSearch: (query: string) => void; onSearch: (query: string) => void;
@ -24,25 +23,28 @@ export function SearchFilter({
}; };
return ( return (
<div className="search-filter"> <div className="bg-muted border-b border-border py-4 px-6 md:py-4 md:px-6 py-3 px-4">
<div className="search-filter-container"> <div className="max-w-[1600px] mx-auto flex gap-4 items-center md:flex-row md:gap-4 md:items-center flex-col items-stretch gap-3">
<form onSubmit={handleSearchSubmit} className="search-form"> <form onSubmit={handleSearchSubmit} className="flex gap-2 flex-1 max-w-md md:max-w-md max-w-full">
<input <input
type="text" type="text"
placeholder="Search videos..." placeholder="Search videos..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="search-input" className="flex-1 px-3.5 py-2.5 border border-border rounded-full text-sm bg-card focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent focus:shadow-lg"
/> />
<button type="submit" className="search-button"> <button
type="submit"
className="px-4.5 py-2.5 bg-gradient-to-r from-primary to-secondary border-none rounded-full cursor-pointer text-base text-white transition-all shadow-lg hover:-translate-y-0.5 hover:shadow-xl"
>
🔍 🔍
</button> </button>
</form> </form>
<div className="filter-controls"> <div className="flex gap-3 items-center md:flex-row md:gap-3 md:items-center flex-wrap">
<select <select
onChange={(e) => onSortChange(e.target.value as any)} onChange={(e) => onSortChange(e.target.value as any)}
className="filter-select" className="px-3 py-2 border border-border rounded-full text-sm bg-card cursor-pointer text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent md:flex-none flex-1"
> >
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="oldest">Oldest</option> <option value="oldest">Oldest</option>
@ -52,7 +54,7 @@ export function SearchFilter({
<select <select
value={selectedChannel || ''} value={selectedChannel || ''}
onChange={(e) => onChannelChange(e.target.value || undefined)} onChange={(e) => onChannelChange(e.target.value || undefined)}
className="filter-select" className="px-3 py-2 border border-border rounded-full text-sm bg-card cursor-pointer text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent md:flex-none flex-1"
> >
<option value="">All Channels</option> <option value="">All Channels</option>
{channels.map(channel => ( {channels.map(channel => (
@ -69,7 +71,7 @@ export function SearchFilter({
onSearch(''); onSearch('');
onChannelChange(undefined); onChannelChange(undefined);
}} }}
className="clear-button" className="px-3 py-2 bg-transparent border border-border rounded-full cursor-pointer text-sm whitespace-nowrap transition-colors text-muted-foreground hover:bg-primary/10 hover:text-foreground md:flex-none flex-1"
> >
Clear Filters Clear Filters
</button> </button>
@ -79,6 +81,3 @@ export function SearchFilter({
</div> </div>
); );
} }

268
frontend/src/components/TimeLimitManager/TimeLimitManager.css

@ -1,268 +0,0 @@
.time-limit-manager {
background: var(--color-surface);
border-radius: 12px;
padding: 24px;
border: 1px solid rgba(212, 222, 239, 0.8);
height: fit-content;
}
.time-limit-header {
margin-bottom: 24px;
}
.time-limit-header h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: var(--color-text);
}
.time-limit-header p {
margin: 0;
font-size: 14px;
color: var(--color-muted);
}
.time-limit-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.time-limit-setting {
display: flex;
flex-direction: column;
gap: 12px;
}
.time-limit-setting label {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
}
.time-limit-input-group {
display: flex;
gap: 12px;
align-items: center;
}
.time-limit-input {
flex: 1;
max-width: 200px;
padding: 10px 12px;
border: 1px solid rgba(212, 222, 239, 0.8);
border-radius: 6px;
font-size: 14px;
background: var(--color-surface-muted);
color: var(--color-text);
transition: border-color 0.2s;
}
.time-limit-input:focus {
outline: none;
border-color: var(--color-primary);
}
.time-limit-save-btn {
padding: 10px 20px;
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.time-limit-save-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3);
}
.time-limit-save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.time-limit-hint {
margin: 0;
font-size: 13px;
color: var(--color-muted);
}
.time-limit-status {
padding: 20px;
background: var(--color-surface-muted);
border-radius: 8px;
border: 1px solid rgba(212, 222, 239, 0.5);
}
.time-limit-status h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
}
.time-limit-progress {
display: flex;
flex-direction: column;
gap: 12px;
}
.time-limit-progress-bar {
width: 100%;
height: 24px;
background: rgba(212, 222, 239, 0.3);
border-radius: 12px;
overflow: hidden;
position: relative;
}
.time-limit-progress-fill {
height: 100%;
background: linear-gradient(90deg, #4a90e2, #357abd);
transition: width 0.3s ease;
border-radius: 12px;
}
.time-limit-stats {
display: flex;
justify-content: space-between;
font-size: 14px;
color: var(--color-text);
}
.time-used strong {
color: var(--color-primary);
}
.time-remaining strong {
color: #4a90e2;
}
.time-limit-actions {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(212, 222, 239, 0.5);
}
.time-limit-reset-btn {
padding: 8px 16px;
background: transparent;
color: var(--color-primary);
border: 1px solid var(--color-primary);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.time-limit-reset-btn:hover {
background: var(--color-primary);
color: white;
}
.time-limit-confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.time-limit-confirm-modal {
background: var(--color-surface);
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.time-limit-confirm-modal h3 {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text);
}
.time-limit-confirm-modal p {
margin: 0 0 20px 0;
font-size: 14px;
color: var(--color-muted);
line-height: 1.5;
}
.time-limit-confirm-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.time-limit-confirm-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.time-limit-confirm-btn.confirm {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
color: white;
}
.time-limit-confirm-btn.confirm:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3);
}
.time-limit-confirm-btn.cancel {
background: transparent;
color: var(--color-text);
border: 1px solid rgba(212, 222, 239, 0.8);
}
.time-limit-confirm-btn.cancel:hover {
background: var(--color-surface-muted);
}
@media (max-width: 768px) {
.time-limit-manager {
padding: 16px;
}
.time-limit-input-group {
flex-direction: column;
align-items: stretch;
}
.time-limit-input {
max-width: 100%;
}
.time-limit-stats {
flex-direction: column;
gap: 8px;
}
.time-limit-confirm-modal {
padding: 20px;
}
.time-limit-confirm-actions {
flex-direction: column;
}
}

77
frontend/src/components/TimeLimitManager/TimeLimitManager.tsx

@ -5,7 +5,6 @@ import {
setDailyLimit, setDailyLimit,
resetDailyCounter resetDailyCounter
} from '../../services/timeLimitService'; } from '../../services/timeLimitService';
import './TimeLimitManager.css';
export function TimeLimitManager() { export function TimeLimitManager() {
const [dailyLimit, setDailyLimitState] = useState<number | null>(null); const [dailyLimit, setDailyLimitState] = useState<number | null>(null);
@ -86,34 +85,36 @@ export function TimeLimitManager() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="time-limit-manager"> <div className="bg-card rounded-xl p-6 border border-border h-fit">
<div className="time-limit-header"> <div className="mb-6">
<h2>Daily Time Limit Settings</h2> <h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Daily Time Limit Settings</h2>
<p>Loading...</p> <p className="m-0 text-sm text-muted-foreground">Loading...</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="time-limit-manager"> <div className="bg-card rounded-xl p-6 border border-border h-fit">
<div className="time-limit-header"> <div className="mb-6">
<h2>Daily Time Limit Settings</h2> <h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Daily Time Limit Settings</h2>
<p>Configure how much time users can spend watching videos each day</p> <p className="m-0 text-sm text-muted-foreground">
Configure how much time users can spend watching videos each day
</p>
</div> </div>
{error && ( {error && (
<div className="alert alert-error" style={{ marginBottom: '16px' }}> <div className="px-4 py-3 rounded-md mb-4 text-sm bg-red-50 text-red-800 border border-red-200">
{error} {error}
</div> </div>
)} )}
<div className="time-limit-section"> <div className="flex flex-col gap-6">
<div className="time-limit-setting"> <div className="flex flex-col gap-3">
<label htmlFor="daily-limit-input"> <label htmlFor="daily-limit-input" className="text-sm font-medium text-foreground">
Daily Limit (minutes) Daily Limit (minutes)
</label> </label>
<div className="time-limit-input-group"> <div className="flex gap-3 items-center md:flex-row flex-col md:items-center items-stretch">
<input <input
id="daily-limit-input" id="daily-limit-input"
type="number" type="number"
@ -126,50 +127,50 @@ export function TimeLimitManager() {
handleSaveLimit(); handleSaveLimit();
} }
}} }}
className="time-limit-input" className="flex-1 max-w-[200px] md:max-w-[200px] max-w-full px-3 py-2.5 border border-border rounded-md text-sm bg-muted text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/> />
<button <button
onClick={handleSaveLimit} onClick={handleSaveLimit}
disabled={isSaving || (dailyLimit !== null && inputValue === dailyLimit.toString())} disabled={isSaving || (dailyLimit !== null && inputValue === dailyLimit.toString())}
className="time-limit-save-btn" className="px-5 py-2.5 bg-gradient-to-r from-primary to-secondary text-white border-none rounded-md text-sm font-medium cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isSaving ? 'Saving...' : 'Save'} {isSaving ? 'Saving...' : 'Save'}
</button> </button>
</div> </div>
{dailyLimit !== null && ( {dailyLimit !== null && (
<p className="time-limit-hint"> <p className="m-0 text-[13px] text-muted-foreground">
Current limit: <strong>{formatTime(dailyLimit)}</strong> per day Current limit: <strong className="font-semibold">{formatTime(dailyLimit)}</strong> per day
</p> </p>
)} )}
</div> </div>
{dailyLimit !== null && ( {dailyLimit !== null && (
<div className="time-limit-status"> <div className="p-5 bg-muted rounded-lg border border-border/50">
<h3>Today's Usage</h3> <h3 className="m-0 mb-4 text-base font-semibold text-foreground">Today's Usage</h3>
<div className="time-limit-progress"> <div className="flex flex-col gap-3">
<div className="time-limit-progress-bar"> <div className="w-full h-6 bg-border/30 rounded-xl overflow-hidden relative">
<div <div
className="time-limit-progress-fill" className="h-full bg-gradient-to-r from-primary to-secondary transition-all duration-300 ease-in-out rounded-xl"
style={{ style={{
width: `${Math.min(100, (timeUsed / dailyLimit) * 100)}%` width: `${Math.min(100, (timeUsed / dailyLimit) * 100)}%`
}} }}
/> />
</div> </div>
<div className="time-limit-stats"> <div className="flex justify-between text-sm text-foreground md:flex-row flex-col md:gap-0 gap-2">
<span className="time-used"> <span className="time-used">
Used: <strong>{formatTime(timeUsed)}</strong> Used: <strong className="text-primary font-semibold">{formatTime(timeUsed)}</strong>
</span> </span>
<span className="time-remaining"> <span className="time-remaining">
Remaining: <strong>{formatTime(remainingTime)}</strong> Remaining: <strong className="text-primary font-semibold">{formatTime(remainingTime)}</strong>
</span> </span>
</div> </div>
</div> </div>
{timeUsed > 0 && ( {timeUsed > 0 && (
<div className="time-limit-actions"> <div className="mt-4 pt-4 border-t border-border/50">
<button <button
onClick={() => setShowResetConfirm(true)} onClick={() => setShowResetConfirm(true)}
className="time-limit-reset-btn" className="px-4 py-2 bg-transparent text-primary border border-primary rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-primary hover:text-primary-foreground"
> >
Reset Today's Counter Reset Today's Counter
</button> </button>
@ -180,22 +181,28 @@ export function TimeLimitManager() {
</div> </div>
{showResetConfirm && ( {showResetConfirm && (
<div className="time-limit-confirm-overlay" onClick={() => setShowResetConfirm(false)}> <div
<div className="time-limit-confirm-modal" onClick={(e) => e.stopPropagation()}> className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000] backdrop-blur-sm"
<h3>Reset Today's Counter?</h3> onClick={() => setShowResetConfirm(false)}
<p> >
<div
className="bg-card rounded-xl p-6 max-w-[400px] w-[90%] shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="m-0 mb-3 text-lg font-semibold text-foreground">Reset Today's Counter?</h3>
<p className="m-0 mb-5 text-sm text-muted-foreground leading-relaxed">
This will reset the time used today back to 0. Users will be able to watch videos again. This will reset the time used today back to 0. Users will be able to watch videos again.
</p> </p>
<div className="time-limit-confirm-actions"> <div className="flex gap-3 justify-end md:flex-row flex-col">
<button <button
onClick={handleResetCounter} onClick={handleResetCounter}
className="time-limit-confirm-btn confirm" className="px-5 py-2.5 bg-gradient-to-r from-primary to-secondary text-white border-none rounded-md text-sm font-medium cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg"
> >
Reset Counter Reset Counter
</button> </button>
<button <button
onClick={() => setShowResetConfirm(false)} onClick={() => setShowResetConfirm(false)}
className="time-limit-confirm-btn cancel" className="px-5 py-2.5 bg-transparent text-foreground border border-border rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-muted"
> >
Cancel Cancel
</button> </button>

118
frontend/src/components/VideoCard/VideoCard.css

@ -1,118 +0,0 @@
.video-card.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.video-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
background: var(--color-surface);
border-radius: 20px;
padding: 16px;
border: 1px solid rgba(212, 222, 239, 0.8);
box-shadow: 0 14px 32px rgba(32, 76, 130, 0.08);
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 18px 40px rgba(32, 76, 130, 0.12);
}
.video-thumbnail-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background-color: var(--color-surface-muted);
border-radius: 12px;
}
.video-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s;
}
.video-card:hover .video-thumbnail {
transform: scale(1.05);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background-color: rgba(31, 42, 55, 0.85);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.video-info {
display: flex;
gap: 12px;
margin-top: 12px;
}
.channel-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.video-details {
flex: 1;
min-width: 0;
}
.video-title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: var(--color-text);
margin: 0 0 6px 0;
overflow: hidden;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.video-metadata {
margin: 0;
font-size: 12px;
color: var(--color-muted);
display: flex;
flex-direction: column;
gap: 2px;
}
.channel-name {
font-weight: 400;
}
.video-stats {
font-weight: 400;
}
@media (max-width: 768px) {
.channel-avatar {
width: 32px;
height: 32px;
}
.video-title {
font-size: 13px;
}
.video-metadata {
font-size: 11px;
}
}

35
frontend/src/components/VideoCard/VideoCard.tsx

@ -1,5 +1,4 @@
import { Video } from '../../types/api'; import { Video } from '../../types/api';
import './VideoCard.css';
interface VideoCardProps { interface VideoCardProps {
video: Video; video: Video;
@ -32,27 +31,36 @@ export function VideoCard({ video, onClick, disabled = false }: VideoCardProps)
}; };
return ( return (
<div className={`video-card ${disabled ? 'disabled' : ''}`} onClick={disabled ? undefined : onClick}> <div
<div className="video-thumbnail-container"> className={`cursor-pointer transition-all bg-card rounded-[20px] p-4 border border-border shadow-lg hover:-translate-y-1 hover:shadow-xl ${
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''
}`}
onClick={disabled ? undefined : onClick}
>
<div className="relative w-full aspect-video overflow-hidden bg-muted rounded-xl group">
<img <img
src={video.thumbnailUrl} src={video.thumbnailUrl}
alt={video.title} alt={video.title}
className="video-thumbnail" className="w-full h-full object-cover transition-transform group-hover:scale-105"
/> />
<span className="video-duration">{video.durationFormatted}</span> <span className="absolute bottom-2 right-2 bg-[rgba(31,42,55,0.85)] text-white py-0.5 px-1.5 rounded text-xs font-medium">
{video.durationFormatted}
</span>
</div> </div>
<div className="video-info"> <div className="flex gap-3 mt-3">
<img <img
src={video.channelThumbnail} src={video.channelThumbnail}
alt={video.channelName} alt={video.channelName}
className="channel-avatar" className="w-9 h-9 md:w-9 md:h-9 w-8 h-8 rounded-full flex-shrink-0"
/> />
<div className="video-details"> <div className="flex-1 min-w-0">
<h3 className="video-title">{video.title}</h3> <h3 className="text-sm font-semibold leading-snug text-foreground m-0 mb-1.5 overflow-hidden line-clamp-2">
<p className="video-metadata"> {video.title}
<span className="channel-name">{video.channelName}</span> </h3>
<span className="video-stats"> <p className="m-0 text-xs text-muted-foreground flex flex-col gap-0.5">
<span className="font-normal">{video.channelName}</span>
<span className="font-normal">
{formatViews(video.viewCount)} views {getTimeAgo(video.publishedAt)} {formatViews(video.viewCount)} views {getTimeAgo(video.publishedAt)}
</span> </span>
</p> </p>
@ -61,6 +69,3 @@ export function VideoCard({ video, onClick, disabled = false }: VideoCardProps)
</div> </div>
); );
} }

167
frontend/src/components/VideoGrid/VideoGrid.css

@ -1,167 +0,0 @@
.video-grid.disabled {
pointer-events: none;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
padding: 32px 24px;
max-width: 1600px;
margin: 0 auto;
}
.skeleton-card {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-thumbnail {
width: 100%;
aspect-ratio: 16 / 9;
background-color: var(--color-surface-muted);
border-radius: 16px;
}
.skeleton-info {
display: flex;
gap: 12px;
margin-top: 12px;
}
.skeleton-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var(--color-surface-muted);
}
.skeleton-text {
flex: 1;
}
.skeleton-title {
height: 16px;
background-color: var(--color-surface-muted);
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-meta {
height: 12px;
width: 60%;
background-color: var(--color-surface-muted);
border-radius: 4px;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.error-message {
text-align: center;
padding: 48px 24px;
color: var(--color-primary-dark);
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--color-muted);
}
.empty-state h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 500;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 32px 24px 48px;
margin: 0 auto;
max-width: 1600px;
}
.pagination-button {
padding: 8px 16px;
background-color: var(--color-secondary);
border: none;
border-radius: 999px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: transform 0.2s, box-shadow 0.2s;
color: white;
box-shadow: 0 12px 26px rgba(32, 76, 130, 0.2);
}
.pagination-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 16px 30px rgba(32, 76, 130, 0.28);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-numbers {
display: flex;
gap: 4px;
}
.pagination-number {
width: 36px;
height: 36px;
background-color: var(--color-surface);
border: 1px solid transparent;
border-radius: 999px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
color: var(--color-text);
}
.pagination-number:hover {
background-color: rgba(47, 128, 237, 0.12);
border-color: rgba(47, 128, 237, 0.5);
}
.pagination-number.active {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
color: white;
border-color: transparent;
}
@media (max-width: 768px) {
.video-grid {
grid-template-columns: 1fr;
gap: 16px;
padding: 16px;
}
.pagination {
padding: 16px;
}
.pagination-numbers {
display: none;
}
}

44
frontend/src/components/VideoGrid/VideoGrid.tsx

@ -1,6 +1,5 @@
import { Video } from '../../types/api'; import { Video } from '../../types/api';
import { VideoCard } from '../VideoCard/VideoCard'; import { VideoCard } from '../VideoCard/VideoCard';
import './VideoGrid.css';
interface VideoGridProps { interface VideoGridProps {
videos: Video[]; videos: Video[];
@ -25,15 +24,15 @@ export function VideoGrid({
}: VideoGridProps) { }: VideoGridProps) {
if (loading) { if (loading) {
return ( return (
<div className="video-grid"> <div className={`grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-6 py-8 px-6 max-w-[1600px] mx-auto ${disabled ? 'pointer-events-none' : ''}`}>
{Array.from({ length: 12 }).map((_, i) => ( {Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="skeleton-card"> <div key={i} className="animate-pulse">
<div className="skeleton-thumbnail"></div> <div className="w-full aspect-video bg-muted rounded-2xl"></div>
<div className="skeleton-info"> <div className="flex gap-3 mt-3">
<div className="skeleton-avatar"></div> <div className="w-9 h-9 rounded-full bg-muted"></div>
<div className="skeleton-text"> <div className="flex-1">
<div className="skeleton-title"></div> <div className="h-4 bg-muted rounded mb-2"></div>
<div className="skeleton-meta"></div> <div className="h-3 bg-muted rounded w-3/5"></div>
</div> </div>
</div> </div>
</div> </div>
@ -44,7 +43,7 @@ export function VideoGrid({
if (error) { if (error) {
return ( return (
<div className="error-message"> <div className="text-center py-12 px-6 text-primary">
<p>Error: {error}</p> <p>Error: {error}</p>
</div> </div>
); );
@ -52,16 +51,16 @@ export function VideoGrid({
if (videos.length === 0) { if (videos.length === 0) {
return ( return (
<div className="empty-state"> <div className="text-center py-12 px-6 text-muted-foreground">
<h2>No videos found</h2> <h2 className="m-0 mb-2 text-xl font-medium">No videos found</h2>
<p>Try adding some channels from the admin panel</p> <p className="m-0 text-sm">Try adding some channels from the admin panel</p>
</div> </div>
); );
} }
return ( return (
<div> <div>
<div className={`video-grid ${disabled ? 'disabled' : ''}`}> <div className={`grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-6 py-8 px-6 max-w-[1600px] mx-auto md:grid-cols-[repeat(auto-fill,minmax(320px,1fr))] md:gap-6 md:py-8 md:px-6 grid-cols-1 gap-4 p-4 ${disabled ? 'pointer-events-none' : ''}`}>
{videos.map(video => ( {videos.map(video => (
<VideoCard <VideoCard
key={video.id} key={video.id}
@ -73,16 +72,16 @@ export function VideoGrid({
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="pagination"> <div className="flex justify-center items-center gap-3 py-8 px-6 mx-auto max-w-[1600px] md:flex-row md:gap-3 md:py-8 md:px-6 flex-col gap-2 p-4">
<button <button
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
disabled={page === 1} disabled={page === 1}
className="pagination-button" className="px-4 py-2 bg-secondary text-secondary-foreground border-none rounded-full cursor-pointer text-sm font-medium transition-all text-white shadow-lg hover:-translate-y-0.5 hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
> >
Previous Previous
</button> </button>
<div className="pagination-numbers"> <div className="flex gap-1 md:flex flex hidden">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum; let pageNum;
if (totalPages <= 5) { if (totalPages <= 5) {
@ -99,7 +98,11 @@ export function VideoGrid({
<button <button
key={pageNum} key={pageNum}
onClick={() => onPageChange(pageNum)} onClick={() => onPageChange(pageNum)}
className={`pagination-number ${page === pageNum ? 'active' : ''}`} className={`w-9 h-9 bg-card border border-transparent rounded-full cursor-pointer text-sm font-medium transition-all text-foreground hover:bg-primary/10 hover:border-primary/50 ${
page === pageNum
? 'bg-gradient-to-r from-primary to-secondary text-white border-transparent'
: ''
}`}
> >
{pageNum} {pageNum}
</button> </button>
@ -110,7 +113,7 @@ export function VideoGrid({
<button <button
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
disabled={page === totalPages} disabled={page === totalPages}
className="pagination-button" className="px-4 py-2 bg-secondary text-secondary-foreground border-none rounded-full cursor-pointer text-sm font-medium transition-all text-white shadow-lg hover:-translate-y-0.5 hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
> >
Next Next
</button> </button>
@ -119,6 +122,3 @@ export function VideoGrid({
</div> </div>
); );
} }

154
frontend/src/components/VideoPlayer/VideoPlayer.css

@ -1,154 +0,0 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
position: relative;
width: 100%;
max-width: 1200px;
background-color: #000;
border-radius: 8px;
overflow: hidden;
}
.close-button {
position: absolute;
top: -40px;
right: 0;
background: none;
border: none;
color: white;
font-size: 40px;
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.close-button:hover {
opacity: 0.7;
}
.video-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
}
.video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.time-remaining-indicator {
position: absolute;
top: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
z-index: 1002;
font-weight: 500;
}
.time-limit-message {
padding: 60px 40px;
text-align: center;
color: white;
min-height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.time-limit-message h2 {
font-size: 28px;
margin-bottom: 16px;
color: #ff6b6b;
}
.time-limit-message p {
font-size: 18px;
margin-bottom: 24px;
opacity: 0.9;
}
.time-limit-button {
background-color: #4a90e2;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.time-limit-button:hover {
background-color: #357abd;
}
@media (max-width: 768px) {
.modal-overlay {
padding: 0;
}
.modal-content {
max-width: 100%;
border-radius: 0;
}
.close-button {
top: 10px;
right: 10px;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 32px;
}
.time-remaining-indicator {
top: 50px;
left: 10px;
font-size: 12px;
padding: 6px 10px;
}
.time-limit-message {
padding: 40px 20px;
}
.time-limit-message h2 {
font-size: 24px;
}
.time-limit-message p {
font-size: 16px;
}
}

41
frontend/src/components/VideoPlayer/VideoPlayer.tsx

@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useTimeLimit } from '../../hooks/useTimeLimit'; import { useTimeLimit } from '../../hooks/useTimeLimit';
import './VideoPlayer.css';
interface VideoPlayerProps { interface VideoPlayerProps {
videoId: string; videoId: string;
@ -69,21 +68,39 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
}; };
return ( return (
<div className="modal-overlay" onClick={handleClose}> <div
<div className="modal-content" onClick={e => e.stopPropagation()}> className="fixed inset-0 bg-black/90 flex items-center justify-center z-[1000] p-5 md:p-5 p-0"
<button className="close-button" onClick={handleClose}>×</button> onClick={handleClose}
>
<div
className="relative w-full max-w-[1200px] bg-black rounded-lg overflow-hidden md:rounded-lg rounded-none md:max-w-[1200px] max-w-full"
onClick={e => e.stopPropagation()}
>
<button
className="absolute -top-10 md:-top-10 top-2.5 right-0 md:right-0 right-2.5 bg-none border-none text-white text-4xl md:text-4xl text-[32px] cursor-pointer p-0 w-10 h-10 md:w-10 md:h-10 w-9 h-9 flex items-center justify-center z-[1001] hover:opacity-70 md:bg-none bg-black/70 md:rounded-none rounded-full"
onClick={handleClose}
>
×
</button>
{limitReached ? ( {limitReached ? (
<div className="time-limit-message"> <div className="py-[60px] px-10 md:py-[60px] md:px-10 py-10 px-5 text-center text-white min-h-[400px] flex flex-col items-center justify-center">
<h2>Daily Time Limit Reached</h2> <h2 className="text-[28px] md:text-[28px] text-2xl mb-4 text-[#ff6b6b]">Daily Time Limit Reached</h2>
<p>You've reached your daily video watching limit. Come back tomorrow!</p> <p className="text-lg md:text-lg text-base mb-6 opacity-90">
<button onClick={handleClose} className="time-limit-button">Close</button> You've reached your daily video watching limit. Come back tomorrow!
</p>
<button
onClick={handleClose}
className="bg-[#4a90e2] text-white border-none py-3 px-6 rounded-md text-base cursor-pointer font-medium transition-colors hover:bg-[#357abd]"
>
Close
</button>
</div> </div>
) : ( ) : (
<> <>
<div className="time-remaining-indicator"> <div className="absolute top-2.5 md:top-2.5 top-[50px] left-2.5 md:left-2.5 left-2.5 bg-black/70 text-white py-2 px-3 rounded text-sm md:text-sm text-xs z-[1002] font-medium">
{Math.floor(remainingTime)} min remaining today {Math.floor(remainingTime)} min remaining today
</div> </div>
<div className="video-container"> <div className="relative w-full pb-[56.25%]">
<iframe <iframe
ref={iframeRef} ref={iframeRef}
width="100%" width="100%"
@ -92,6 +109,7 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
title="YouTube video player" title="YouTube video player"
className="absolute top-0 left-0 w-full h-full border-none"
/> />
</div> </div>
</> </>
@ -100,6 +118,3 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
</div> </div>
); );
} }

371
frontend/src/components/WordGroupManager/WordGroupManager.css

@ -1,371 +0,0 @@
.word-group-manager {
width: 100%;
padding: 24px;
background: var(--color-surface);
border-radius: 12px;
border: 1px solid rgba(212, 222, 239, 0.8);
}
.word-group-header {
margin-bottom: 24px;
}
.word-group-header h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: var(--color-text);
}
.word-group-header p {
margin: 0;
font-size: 14px;
color: var(--color-muted);
}
.create-group-form {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.group-name-input {
flex: 1;
padding: 12px 16px;
border: 1px solid rgba(212, 222, 239, 0.8);
border-radius: 6px;
font-size: 14px;
background: var(--color-surface-muted);
color: var(--color-text);
transition: border-color 0.2s;
}
.group-name-input:focus {
outline: none;
border-color: var(--color-primary);
}
.create-group-btn {
padding: 12px 24px;
background: linear-gradient(
135deg,
var(--color-primary),
var(--color-secondary)
);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
white-space: nowrap;
}
.create-group-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3);
}
.create-group-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--color-muted);
}
.empty-state p {
margin: 0;
font-size: 14px;
}
.groups-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.word-group-card {
background: var(--color-surface-muted);
border: 1px solid rgba(212, 222, 239, 0.5);
border-radius: 8px;
overflow: hidden;
}
.group-header {
padding: 16px;
background: var(--color-surface);
border-bottom: 1px solid rgba(212, 222, 239, 0.5);
}
.group-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.group-name {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: color 0.2s;
}
.group-name:hover {
color: var(--color-primary);
}
.word-count {
font-size: 14px;
font-weight: 400;
color: var(--color-muted);
}
.group-actions {
display: flex;
gap: 8px;
}
.edit-btn,
.delete-btn {
background: transparent;
border: none;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.edit-btn:hover,
.delete-btn:hover {
background: rgba(212, 222, 239, 0.3);
}
.edit-group-form {
display: flex;
gap: 8px;
align-items: center;
}
.edit-group-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 4px;
font-size: 16px;
font-weight: 600;
background: var(--color-surface-muted);
color: var(--color-text);
}
.edit-group-input:focus {
outline: none;
border-color: var(--color-primary);
}
.save-btn,
.cancel-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.save-btn {
background: var(--color-primary);
color: white;
}
.save-btn:hover {
background: var(--color-secondary);
}
.cancel-btn {
background: transparent;
color: var(--color-text);
border: 1px solid rgba(212, 222, 239, 0.8);
}
.cancel-btn:hover {
background: var(--color-surface-muted);
}
.group-content {
padding: 16px;
}
.words-list {
margin-bottom: 16px;
}
.no-words {
text-align: center;
color: var(--color-muted);
font-size: 14px;
margin: 16px 0;
}
.words-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.word-item {
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: var(--color-surface);
border: 1px solid rgba(212, 222, 239, 0.5);
border-radius: 999px;
transition:
border-color 0.2s,
background-color 0.2s;
white-space: nowrap;
}
.word-item:hover {
border-color: var(--color-primary);
background: var(--color-surface-muted);
}
.practice-area {
.word-text {
font-size: 48px;
text-transform: none;
}
}
.word-text {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
margin-right: 8px;
line-height: 1.2;
display: inline-block;
vertical-align: middle;
margin-bottom: 0;
}
.delete-word-btn {
background: transparent;
border: none;
color: var(--color-muted);
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
margin-left: 4px;
vertical-align: middle;
transition:
background-color 0.2s,
color 0.2s;
}
.delete-word-btn:hover {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.add-word-form {
display: flex;
gap: 8px;
}
.word-input {
flex: 1;
padding: 10px 12px;
border: 1px solid rgba(212, 222, 239, 0.8);
border-radius: 6px;
font-size: 14px;
background: var(--color-surface);
color: var(--color-text);
transition: border-color 0.2s;
}
.word-input:focus {
outline: none;
border-color: var(--color-primary);
}
.add-word-btn {
padding: 10px 20px;
background: var(--color-primary);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.add-word-btn:hover:not(:disabled) {
background: var(--color-secondary);
}
.add-word-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.alert-error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
@media (max-width: 768px) {
.word-group-manager {
padding: 16px;
}
.create-group-form {
flex-direction: column;
}
.group-info {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.group-actions {
align-self: flex-end;
}
.add-word-form {
flex-direction: column;
}
}

89
frontend/src/components/WordGroupManager/WordGroupManager.tsx

@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { wordGroupsApi } from '../../services/apiClient'; import { wordGroupsApi } from '../../services/apiClient';
import './WordGroupManager.css';
interface Word { interface Word {
id: number; id: number;
@ -163,50 +162,56 @@ export function WordGroupManager() {
if (loading) { if (loading) {
return ( return (
<div className="word-group-manager"> <div className="w-full p-6 bg-card rounded-xl border border-border">
<div className="word-group-header"> <div className="mb-6">
<h2>Speech Sounds - Word Groups</h2> <h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Speech Sounds - Word Groups</h2>
<p>Loading...</p> <p className="m-0 text-sm text-muted-foreground">Loading...</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="word-group-manager"> <div className="w-full p-6 bg-card rounded-xl border border-border">
<div className="word-group-header"> <div className="mb-6">
<h2>Speech Sounds - Word Groups</h2> <h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Speech Sounds - Word Groups</h2>
<p>Create groups of words to help practice speech sounds</p> <p className="m-0 text-sm text-muted-foreground">Create groups of words to help practice speech sounds</p>
</div> </div>
{error && ( {error && (
<div className="alert alert-error">{error}</div> <div className="px-4 py-3 rounded-md mb-4 text-sm bg-red-50 text-red-800 border border-red-200">
{error}
</div>
)} )}
<form onSubmit={handleCreateGroup} className="create-group-form"> <form onSubmit={handleCreateGroup} className="flex gap-3 mb-6 md:flex-row flex-col">
<input <input
type="text" type="text"
placeholder="Enter group name (e.g., 'R Sounds', 'S Blends')..." placeholder="Enter group name (e.g., 'R Sounds', 'S Blends')..."
value={newGroupName} value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)} onChange={(e) => setNewGroupName(e.target.value)}
className="group-name-input" className="flex-1 px-4 py-3 border border-border rounded-md text-sm bg-muted text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/> />
<button type="submit" disabled={!newGroupName.trim()} className="create-group-btn"> <button
type="submit"
disabled={!newGroupName.trim()}
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary text-white border-none rounded-md text-sm font-medium cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
Create Group Create Group
</button> </button>
</form> </form>
{groups.length === 0 ? ( {groups.length === 0 ? (
<div className="empty-state"> <div className="text-center py-12 px-6 text-muted-foreground">
<p>No word groups yet. Create your first group above!</p> <p className="m-0 text-sm">No word groups yet. Create your first group above!</p>
</div> </div>
) : ( ) : (
<div className="groups-list"> <div className="flex flex-col gap-4">
{groups.map(group => ( {groups.map(group => (
<div key={group.id} className="word-group-card"> <div key={group.id} className="bg-muted border border-border/50 rounded-lg overflow-hidden">
<div className="group-header"> <div className="p-4 bg-card border-b border-border/50">
{editingGroupId === group.id ? ( {editingGroupId === group.id ? (
<div className="edit-group-form"> <div className="flex gap-2 items-center">
<input <input
type="text" type="text"
value={editingGroupName} value={editingGroupName}
@ -218,40 +223,43 @@ export function WordGroupManager() {
cancelEditing(); cancelEditing();
} }
}} }}
className="edit-group-input" className="flex-1 px-3 py-2 border border-primary rounded-md text-base font-semibold bg-muted text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
autoFocus autoFocus
/> />
<button <button
onClick={() => handleUpdateGroup(group.id)} onClick={() => handleUpdateGroup(group.id)}
className="save-btn" className="px-4 py-2 bg-primary text-white border-none rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-secondary"
> >
Save Save
</button> </button>
<button <button
onClick={cancelEditing} onClick={cancelEditing}
className="cancel-btn" className="px-4 py-2 bg-transparent text-foreground border border-border rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-muted"
> >
Cancel Cancel
</button> </button>
</div> </div>
) : ( ) : (
<> <>
<div className="group-info"> <div className="flex justify-between items-center md:flex-row flex-col md:items-center items-start gap-3">
<h3 className="group-name" onClick={() => toggleGroup(group.id)}> <h3
className="m-0 text-lg font-semibold text-foreground cursor-pointer flex items-center gap-2 transition-colors hover:text-primary"
onClick={() => toggleGroup(group.id)}
>
{group.name} {group.name}
<span className="word-count">({group.wordCount} words)</span> <span className="text-sm font-normal text-muted-foreground">({group.wordCount} words)</span>
</h3> </h3>
<div className="group-actions"> <div className="flex gap-2 md:self-auto self-end">
<button <button
onClick={() => startEditing(group)} onClick={() => startEditing(group)}
className="edit-btn" className="bg-transparent border-none text-lg cursor-pointer p-1 rounded transition-colors hover:bg-muted"
title="Edit group name" title="Edit group name"
> >
</button> </button>
<button <button
onClick={() => handleDeleteGroup(group.id, group.name)} onClick={() => handleDeleteGroup(group.id, group.name)}
className="delete-btn" className="bg-transparent border-none text-lg cursor-pointer p-1 rounded transition-colors hover:bg-muted"
title="Delete group" title="Delete group"
> >
🗑 🗑
@ -263,18 +271,23 @@ export function WordGroupManager() {
</div> </div>
{expandedGroups.has(group.id) && ( {expandedGroups.has(group.id) && (
<div className="group-content"> <div className="p-4">
<div className="words-list"> <div className="mb-4">
{group.words.length === 0 ? ( {group.words.length === 0 ? (
<p className="no-words">No words yet. Add words below.</p> <p className="text-center text-muted-foreground text-sm my-4">No words yet. Add words below.</p>
) : ( ) : (
<div className="words-grid"> <div className="flex flex-wrap gap-2 mb-4">
{group.words.map(word => ( {group.words.map(word => (
<div key={word.id} className="word-item"> <div
<span className="word-text">{word.word}</span> key={word.id}
className="inline-flex items-center justify-between px-4 py-2 bg-card border border-border/50 rounded-full transition-all hover:border-primary hover:bg-muted whitespace-nowrap"
>
<span className="text-sm font-medium text-foreground mr-2 leading-tight inline-block align-middle mb-0">
{word.word}
</span>
<button <button
onClick={() => handleDeleteWord(word.id, group.id, word.word)} onClick={() => handleDeleteWord(word.id, group.id, word.word)}
className="delete-word-btn" className="bg-transparent border-none text-muted-foreground text-xl leading-none cursor-pointer p-0 w-5 h-5 inline-flex items-center justify-center rounded-full flex-shrink-0 ml-1 align-middle transition-colors hover:bg-red-100 hover:text-red-600"
title="Delete word" title="Delete word"
> >
× ×
@ -290,7 +303,7 @@ export function WordGroupManager() {
e.preventDefault(); e.preventDefault();
handleAddWord(group.id); handleAddWord(group.id);
}} }}
className="add-word-form" className="flex gap-2 md:flex-row flex-col"
> >
<input <input
type="text" type="text"
@ -303,12 +316,12 @@ export function WordGroupManager() {
handleAddWord(group.id); handleAddWord(group.id);
} }
}} }}
className="word-input" className="flex-1 px-3 py-2.5 border border-border rounded-md text-sm bg-card text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/> />
<button <button
type="submit" type="submit"
disabled={!newWordInputs[group.id]?.trim()} disabled={!newWordInputs[group.id]?.trim()}
className="add-word-btn" className="px-5 py-2.5 bg-primary text-white border-none rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
> >
Add Word Add Word
</button> </button>

206
frontend/src/pages/AdminPage.css

@ -1,206 +0,0 @@
.admin-page {
min-height: calc(100vh - 60px);
background-color: #f9f9f9;
}
.admin-header {
background-color: #fff;
border-bottom: 1px solid #e5e5e5;
padding: 32px 24px;
text-align: center;
}
.admin-header h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 500;
color: #030303;
}
.admin-header p {
margin: 0;
font-size: 14px;
color: #606060;
}
.back-button {
margin-bottom: 16px;
padding: 8px 16px;
background: transparent;
border: 1px solid #e5e5e5;
border-radius: 6px;
color: #030303;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.back-button:hover {
background-color: #f5f5f5;
}
.admin-section-link {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e5e5e5;
}
.section-link-button {
display: block;
width: 100%;
padding: 12px 20px;
background: linear-gradient(135deg, #4a90e2, #357abd);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
text-align: center;
text-decoration: none;
}
.section-link-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3);
color: white;
}
.back-button {
display: inline-block;
margin-bottom: 16px;
padding: 8px 16px;
background: transparent;
border: 1px solid #e5e5e5;
border-radius: 6px;
color: #030303;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
text-decoration: none;
}
.back-button:hover {
background-color: #f5f5f5;
color: #030303;
}
.admin-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
.admin-column {
display: flex;
flex-direction: column;
}
.admin-links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.admin-link-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 32px 24px;
background: white;
border: 1px solid #e5e5e5;
border-radius: 12px;
text-decoration: none;
color: #030303;
transition: transform 0.2s, box-shadow 0.2s;
}
.admin-link-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #030303;
}
.admin-link-icon {
font-size: 48px;
margin-bottom: 16px;
}
.admin-link-card h2 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 500;
color: #030303;
}
.admin-link-card p {
margin: 0;
font-size: 14px;
color: #606060;
line-height: 1.5;
}
.refresh-videos-button {
padding: 10px 20px;
background: var(--color-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 8px var(--color-shadow);
}
.refresh-videos-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3);
}
.refresh-videos-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.refresh-message {
margin-top: 12px;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.refresh-message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.refresh-message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@media (max-width: 1024px) {
.admin-content {
grid-template-columns: 1fr;
}
.admin-links-grid {
grid-template-columns: 1fr;
padding: 16px;
}
}

40
frontend/src/pages/AdminPage.tsx

@ -1,30 +1,36 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import './AdminPage.css';
export function AdminPage() { export function AdminPage() {
return ( return (
<div className="admin-page"> <div className="min-h-[calc(100vh-60px)] bg-background">
<div className="admin-header"> <div className="bg-card border-b border-border py-8 px-6 text-center">
<h1>Admin Dashboard</h1> <h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Admin Dashboard</h1>
<p>Manage app settings and configurations</p> <p className="m-0 text-sm text-muted-foreground">Manage app settings and configurations</p>
</div> </div>
<div className="admin-links-grid"> <div className="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] gap-6 p-6 max-w-[1200px] mx-auto">
<Link to="/admin/videos" className="admin-link-card"> <Link
<div className="admin-link-icon">📹</div> to="/admin/videos"
<h2>Video App</h2> className="flex flex-col items-center text-center py-8 px-6 bg-card border border-border rounded-xl no-underline text-foreground transition-all hover:-translate-y-1 hover:shadow-lg group"
<p>Manage YouTube channels and video time limits</p> >
<div className="text-5xl mb-4">📹</div>
<h2 className="m-0 mb-2 text-2xl font-medium text-foreground">Video App</h2>
<p className="m-0 text-sm text-muted-foreground leading-relaxed">
Manage YouTube channels and video time limits
</p>
</Link> </Link>
<Link to="/admin/speech-sounds" className="admin-link-card"> <Link
<div className="admin-link-icon">🗣</div> to="/admin/speech-sounds"
<h2>Speech Sounds</h2> className="flex flex-col items-center text-center py-8 px-6 bg-card border border-border rounded-xl no-underline text-foreground transition-all hover:-translate-y-1 hover:shadow-lg group"
<p>Manage word groups for speech sound practice</p> >
<div className="text-5xl mb-4">🗣</div>
<h2 className="m-0 mb-2 text-2xl font-medium text-foreground">Speech Sounds</h2>
<p className="m-0 text-sm text-muted-foreground leading-relaxed">
Manage word groups for speech sound practice
</p>
</Link> </Link>
</div> </div>
</div> </div>
); );
} }

143
frontend/src/pages/LandingPage.css

@ -1,143 +0,0 @@
.landing-page {
max-width: 1100px;
margin: 0 auto;
padding: 48px 24px 72px;
color: var(--color-text);
}
.landing-page.menu {
display: flex;
flex-direction: column;
gap: 32px;
}
.menu-header h1 {
font-size: 2.5rem;
margin: 12px 0;
font-family: var(--font-playful);
font-weight: 800;
background: var(--gradient-primary-text);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.menu-header p {
color: var(--color-muted);
margin: 0;
font-weight: 600;
font-size: 1.1rem;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.8rem;
color: var(--color-secondary-dark);
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.app-card {
border-radius: 24px;
background: var(--color-surface);
border: 3px solid var(--color-primary);
padding: 28px;
box-shadow: 0 8px 24px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.app-card:hover:not(.disabled) {
transform: translateY(-6px) scale(1.02);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.25);
border-color: var(--color-secondary);
}
.app-card header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.app-card h2 {
margin: 0;
font-size: 1.5rem;
font-family: var(--font-playful);
font-weight: 800;
background: var(--gradient-primary-text);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-card p {
margin: 0;
color: var(--color-muted);
flex: 1;
}
.app-card .tag {
font-size: 0.75rem;
background: rgba(31, 59, 115, 0.08);
color: var(--color-primary-dark);
padding: 4px 10px;
border-radius: 999px;
}
.app-actions {
margin-top: auto;
}
.app-actions a,
.app-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
border-radius: 25px;
font-weight: 700;
font-family: var(--font-playful);
text-decoration: none;
border: 3px solid var(--color-primary);
background: var(--color-primary);
color: white;
box-shadow: 0 4px 12px var(--color-shadow);
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.app-actions a:hover,
.app-actions button:hover:not(:disabled) {
transform: translateY(-4px) scale(1.05);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.4);
border-color: var(--color-secondary);
}
.app-card.disabled {
opacity: 0.5;
}
.app-card.disabled .app-actions button {
background: transparent;
color: var(--color-muted);
border: 1px dashed var(--color-border);
box-shadow: none;
}
@media (max-width: 600px) {
.landing-page {
padding: 32px 16px 56px;
}
.menu-header h1 {
font-size: 2rem;
}
}

101
frontend/src/pages/LoginPage.css

@ -1,101 +0,0 @@
.login-page {
min-height: calc(100vh - 60px);
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
padding: 24px;
}
.login-container {
width: 100%;
max-width: 400px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.login-header {
padding: 32px 32px 24px;
text-align: center;
border-bottom: 1px solid #e5e5e5;
}
.login-header h1 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 500;
color: #030303;
}
.login-header p {
margin: 0;
font-size: 14px;
color: #606060;
}
.login-form {
padding: 32px;
}
.login-error {
padding: 12px;
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
border-radius: 4px;
font-size: 14px;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: #030303;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #065fd4;
}
.login-button {
width: 100%;
padding: 12px;
background-color: #065fd4;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.login-button:hover:not(:disabled) {
background-color: #0556c4;
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

18
frontend/src/pages/SpeechSoundsAdminPage.tsx

@ -1,18 +1,22 @@
import { WordGroupManager } from '../components/WordGroupManager/WordGroupManager'; import { WordGroupManager } from '../components/WordGroupManager/WordGroupManager';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import './AdminPage.css';
export function SpeechSoundsAdminPage() { export function SpeechSoundsAdminPage() {
return ( return (
<div className="admin-page"> <div className="min-h-[calc(100vh-60px)] bg-background">
<div className="admin-header"> <div className="bg-card border-b border-border py-8 px-6 text-center">
<Link to="/admin" className="back-button"> <Link
to="/admin"
className="inline-block mb-4 px-4 py-2 bg-transparent border border-border rounded-md text-foreground text-sm cursor-pointer transition-colors no-underline hover:bg-muted"
>
Back to Admin Back to Admin
</Link> </Link>
<h1>Speech Sounds - Word Groups</h1> <h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Speech Sounds - Word Groups</h1>
<p>Manage word groups for speech sound practice</p> <p className="m-0 text-sm text-muted-foreground">Manage word groups for speech sound practice</p>
</div>
<div className="max-w-[1200px] mx-auto p-6">
<WordGroupManager />
</div> </div>
<WordGroupManager />
</div> </div>
); );
} }

13
frontend/src/pages/VideoApp.css

@ -1,13 +0,0 @@
.time-limit-banner {
background-color: #ff6b6b;
color: white;
padding: 12px 20px;
text-align: center;
font-weight: 500;
margin-bottom: 20px;
border-radius: 4px;
}
.time-limit-banner p {
margin: 0;
}

5
frontend/src/pages/VideoApp.tsx

@ -4,7 +4,6 @@ import { useVideos } from '../hooks/useVideos';
import { useTimeLimit } from '../hooks/useTimeLimit'; import { useTimeLimit } from '../hooks/useTimeLimit';
import { VideoGrid } from '../components/VideoGrid/VideoGrid'; import { VideoGrid } from '../components/VideoGrid/VideoGrid';
import { VideoPlayer } from '../components/VideoPlayer/VideoPlayer'; import { VideoPlayer } from '../components/VideoPlayer/VideoPlayer';
import './VideoApp.css';
export function VideoApp() { export function VideoApp() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -44,8 +43,8 @@ export function VideoApp() {
return ( return (
<div> <div>
{limitReached && ( {limitReached && (
<div className="time-limit-banner"> <div className="bg-[#ff6b6b] text-white py-3 px-5 text-center font-medium mb-5 rounded-md">
<p>Daily time limit reached. Videos are disabled until tomorrow.</p> <p className="m-0">Daily time limit reached. Videos are disabled until tomorrow.</p>
</div> </div>
)} )}

32
frontend/src/pages/VideosAdminPage.tsx

@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import { ChannelManager } from '../components/ChannelManager/ChannelManager'; import { ChannelManager } from '../components/ChannelManager/ChannelManager';
import { TimeLimitManager } from '../components/TimeLimitManager/TimeLimitManager'; import { TimeLimitManager } from '../components/TimeLimitManager/TimeLimitManager';
import { videosApi } from '../services/apiClient'; import { videosApi } from '../services/apiClient';
import './AdminPage.css';
export function VideosAdminPage() { export function VideosAdminPage() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@ -29,35 +28,42 @@ export function VideosAdminPage() {
}; };
return ( return (
<div className="admin-page"> <div className="min-h-[calc(100vh-60px)] bg-background">
<div className="admin-header"> <div className="bg-card border-b border-border py-8 px-6 text-center">
<Link to="/admin" className="back-button"> <Link
to="/admin"
className="inline-block mb-4 px-4 py-2 bg-transparent border border-border rounded-md text-foreground text-sm cursor-pointer transition-colors no-underline hover:bg-muted"
>
Back to Admin Back to Admin
</Link> </Link>
<h1>Video App Settings</h1> <h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Video App Settings</h1>
<p>Manage YouTube channels and video time limits</p> <p className="m-0 text-sm text-muted-foreground">Manage YouTube channels and video time limits</p>
<div style={{ marginTop: '16px' }}> <div className="mt-4">
<button <button
onClick={handleRefreshVideos} onClick={handleRefreshVideos}
disabled={refreshing} disabled={refreshing}
className="refresh-videos-button" className="px-5 py-2.5 bg-primary text-primary-foreground border-none rounded-lg text-sm font-semibold cursor-pointer transition-all shadow-md hover:-translate-y-0.5 hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
> >
{refreshing ? 'Refreshing...' : '🔄 Refresh All Videos'} {refreshing ? 'Refreshing...' : '🔄 Refresh All Videos'}
</button> </button>
{refreshMessage && ( {refreshMessage && (
<div className="refresh-message success">{refreshMessage}</div> <div className="mt-3 px-3 py-2 rounded-md text-sm font-medium bg-green-100 text-green-800 border border-green-200">
{refreshMessage}
</div>
)} )}
{refreshError && ( {refreshError && (
<div className="refresh-message error">{refreshError}</div> <div className="mt-3 px-3 py-2 rounded-md text-sm font-medium bg-red-100 text-red-800 border border-red-200">
{refreshError}
</div>
)} )}
</div> </div>
</div> </div>
<div className="admin-content"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6 max-w-[1600px] mx-auto">
<div className="admin-column"> <div className="flex flex-col">
<ChannelManager /> <ChannelManager />
</div> </div>
<div className="admin-column"> <div className="flex flex-col">
<TimeLimitManager /> <TimeLimitManager />
</div> </div>
</div> </div>

Loading…
Cancel
Save