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

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

@ -1,178 +0,0 @@ @@ -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 @@ @@ -1,6 +1,5 @@
import { useState } from 'react';
import { useChannels } from '../../hooks/useChannels';
import './ChannelManager.css';
export function ChannelManager() {
const { channels, loading, error, addChannel, removeChannel } = useChannels();
@ -48,59 +47,80 @@ export function ChannelManager() { @@ -48,59 +47,80 @@ export function ChannelManager() {
};
return (
<div className="channel-manager">
<h2>Channel Management</h2>
<div className="w-full p-6 bg-card rounded-xl border border-border">
<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
type="text"
placeholder="Enter channel ID, @handle, or YouTube URL..."
value={channelInput}
onChange={(e) => setChannelInput(e.target.value)}
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'}
</button>
</form>
{addError && <div className="alert alert-error">{addError}</div>}
{addSuccess && <div className="alert alert-success">{addSuccess}</div>}
{addError && (
<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>}
{error && <div className="alert alert-error">{error}</div>}
{loading && <p className="text-foreground">Loading channels...</p>}
{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 && (
<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 && (
<div className="channels-list">
<div className="flex flex-col gap-4">
{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
src={channel.thumbnailUrl}
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">
<h3 className="channel-name">{channel.name}</h3>
<p className="channel-stats">
<div className="flex-1 min-w-0">
<h3 className="m-0 mb-1 text-base font-medium text-foreground">{channel.name}</h3>
<p className="m-0 mb-1 text-sm text-muted-foreground">
{formatNumber(channel.subscriberCount)} subscribers {channel.videoCount} videos
</p>
{channel.lastFetchedAt && (
<p className="channel-meta">
<p className="m-0 text-xs text-muted-foreground/70">
Last updated: {new Date(channel.lastFetchedAt).toLocaleString()}
</p>
)}
{channel.fetchError && (
<p className="channel-error">Error: {channel.fetchError}</p>
<p className="m-0 text-xs text-red-600">Error: {channel.fetchError}</p>
)}
</div>
<button
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
</button>
@ -111,6 +131,3 @@ export function ChannelManager() { @@ -111,6 +131,3 @@ export function ChannelManager() {
</div>
);
}

11
frontend/src/components/ErrorBoundary.tsx

@ -26,10 +26,13 @@ export class ErrorBoundary extends React.Component<Props, State> { @@ -26,10 +26,13 @@ export class ErrorBoundary extends React.Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<div className="error-container">
<h1>Something went wrong</h1>
<p>{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}>
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
<h1 className="text-2xl mb-4 text-primary">Something went wrong</h1>
<p className="text-sm text-muted-foreground mb-6">{this.state.error?.message}</p>
<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
</button>
</div>

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

@ -1,233 +0,0 @@ @@ -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() { @@ -89,15 +89,6 @@ export function Navbar() {
Home
</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 ? (
<button
onClick={handleLogout}

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

@ -1,121 +0,0 @@ @@ -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 @@ @@ -1,5 +1,4 @@
import { useState } from 'react';
import './SearchFilter.css';
interface SearchFilterProps {
onSearch: (query: string) => void;
@ -24,25 +23,28 @@ export function SearchFilter({ @@ -24,25 +23,28 @@ export function SearchFilter({
};
return (
<div className="search-filter">
<div className="search-filter-container">
<form onSubmit={handleSearchSubmit} className="search-form">
<div className="bg-muted border-b border-border py-4 px-6 md:py-4 md:px-6 py-3 px-4">
<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="flex gap-2 flex-1 max-w-md md:max-w-md max-w-full">
<input
type="text"
placeholder="Search videos..."
value={searchQuery}
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>
</form>
<div className="filter-controls">
<div className="flex gap-3 items-center md:flex-row md:gap-3 md:items-center flex-wrap">
<select
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="oldest">Oldest</option>
@ -52,7 +54,7 @@ export function SearchFilter({ @@ -52,7 +54,7 @@ export function SearchFilter({
<select
value={selectedChannel || ''}
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>
{channels.map(channel => (
@ -69,7 +71,7 @@ export function SearchFilter({ @@ -69,7 +71,7 @@ export function SearchFilter({
onSearch('');
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
</button>
@ -79,6 +81,3 @@ export function SearchFilter({ @@ -79,6 +81,3 @@ export function SearchFilter({
</div>
);
}

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

@ -1,268 +0,0 @@ @@ -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 { @@ -5,7 +5,6 @@ import {
setDailyLimit,
resetDailyCounter
} from '../../services/timeLimitService';
import './TimeLimitManager.css';
export function TimeLimitManager() {
const [dailyLimit, setDailyLimitState] = useState<number | null>(null);
@ -86,34 +85,36 @@ export function TimeLimitManager() { @@ -86,34 +85,36 @@ export function TimeLimitManager() {
if (isLoading) {
return (
<div className="time-limit-manager">
<div className="time-limit-header">
<h2>Daily Time Limit Settings</h2>
<p>Loading...</p>
<div className="bg-card rounded-xl p-6 border border-border h-fit">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Daily Time Limit Settings</h2>
<p className="m-0 text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
return (
<div className="time-limit-manager">
<div className="time-limit-header">
<h2>Daily Time Limit Settings</h2>
<p>Configure how much time users can spend watching videos each day</p>
<div className="bg-card rounded-xl p-6 border border-border h-fit">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Daily Time Limit Settings</h2>
<p className="m-0 text-sm text-muted-foreground">
Configure how much time users can spend watching videos each day
</p>
</div>
{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}
</div>
)}
<div className="time-limit-section">
<div className="time-limit-setting">
<label htmlFor="daily-limit-input">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<label htmlFor="daily-limit-input" className="text-sm font-medium text-foreground">
Daily Limit (minutes)
</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
id="daily-limit-input"
type="number"
@ -126,50 +127,50 @@ export function TimeLimitManager() { @@ -126,50 +127,50 @@ export function TimeLimitManager() {
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
onClick={handleSaveLimit}
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'}
</button>
</div>
{dailyLimit !== null && (
<p className="time-limit-hint">
Current limit: <strong>{formatTime(dailyLimit)}</strong> per day
<p className="m-0 text-[13px] text-muted-foreground">
Current limit: <strong className="font-semibold">{formatTime(dailyLimit)}</strong> per day
</p>
)}
</div>
{dailyLimit !== null && (
<div className="time-limit-status">
<h3>Today's Usage</h3>
<div className="time-limit-progress">
<div className="time-limit-progress-bar">
<div className="p-5 bg-muted rounded-lg border border-border/50">
<h3 className="m-0 mb-4 text-base font-semibold text-foreground">Today's Usage</h3>
<div className="flex flex-col gap-3">
<div className="w-full h-6 bg-border/30 rounded-xl overflow-hidden relative">
<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={{
width: `${Math.min(100, (timeUsed / dailyLimit) * 100)}%`
}}
/>
</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">
Used: <strong>{formatTime(timeUsed)}</strong>
Used: <strong className="text-primary font-semibold">{formatTime(timeUsed)}</strong>
</span>
<span className="time-remaining">
Remaining: <strong>{formatTime(remainingTime)}</strong>
Remaining: <strong className="text-primary font-semibold">{formatTime(remainingTime)}</strong>
</span>
</div>
</div>
{timeUsed > 0 && (
<div className="time-limit-actions">
<div className="mt-4 pt-4 border-t border-border/50">
<button
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
</button>
@ -180,22 +181,28 @@ export function TimeLimitManager() { @@ -180,22 +181,28 @@ export function TimeLimitManager() {
</div>
{showResetConfirm && (
<div className="time-limit-confirm-overlay" onClick={() => setShowResetConfirm(false)}>
<div className="time-limit-confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset Today's Counter?</h3>
<p>
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000] backdrop-blur-sm"
onClick={() => setShowResetConfirm(false)}
>
<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.
</p>
<div className="time-limit-confirm-actions">
<div className="flex gap-3 justify-end md:flex-row flex-col">
<button
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
</button>
<button
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
</button>

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

@ -1,118 +0,0 @@ @@ -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 @@ @@ -1,5 +1,4 @@
import { Video } from '../../types/api';
import './VideoCard.css';
interface VideoCardProps {
video: Video;
@ -32,27 +31,36 @@ export function VideoCard({ video, onClick, disabled = false }: VideoCardProps) @@ -32,27 +31,36 @@ export function VideoCard({ video, onClick, disabled = false }: VideoCardProps)
};
return (
<div className={`video-card ${disabled ? 'disabled' : ''}`} onClick={disabled ? undefined : onClick}>
<div className="video-thumbnail-container">
<div
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
src={video.thumbnailUrl}
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 className="video-info">
<div className="flex gap-3 mt-3">
<img
src={video.channelThumbnail}
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">
<h3 className="video-title">{video.title}</h3>
<p className="video-metadata">
<span className="channel-name">{video.channelName}</span>
<span className="video-stats">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold leading-snug text-foreground m-0 mb-1.5 overflow-hidden line-clamp-2">
{video.title}
</h3>
<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)}
</span>
</p>
@ -61,6 +69,3 @@ export function VideoCard({ video, onClick, disabled = false }: VideoCardProps) @@ -61,6 +69,3 @@ export function VideoCard({ video, onClick, disabled = false }: VideoCardProps)
</div>
);
}

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

@ -1,167 +0,0 @@ @@ -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 @@ @@ -1,6 +1,5 @@
import { Video } from '../../types/api';
import { VideoCard } from '../VideoCard/VideoCard';
import './VideoGrid.css';
interface VideoGridProps {
videos: Video[];
@ -25,15 +24,15 @@ export function VideoGrid({ @@ -25,15 +24,15 @@ export function VideoGrid({
}: VideoGridProps) {
if (loading) {
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) => (
<div key={i} className="skeleton-card">
<div className="skeleton-thumbnail"></div>
<div className="skeleton-info">
<div className="skeleton-avatar"></div>
<div className="skeleton-text">
<div className="skeleton-title"></div>
<div className="skeleton-meta"></div>
<div key={i} className="animate-pulse">
<div className="w-full aspect-video bg-muted rounded-2xl"></div>
<div className="flex gap-3 mt-3">
<div className="w-9 h-9 rounded-full bg-muted"></div>
<div className="flex-1">
<div className="h-4 bg-muted rounded mb-2"></div>
<div className="h-3 bg-muted rounded w-3/5"></div>
</div>
</div>
</div>
@ -44,7 +43,7 @@ export function VideoGrid({ @@ -44,7 +43,7 @@ export function VideoGrid({
if (error) {
return (
<div className="error-message">
<div className="text-center py-12 px-6 text-primary">
<p>Error: {error}</p>
</div>
);
@ -52,16 +51,16 @@ export function VideoGrid({ @@ -52,16 +51,16 @@ export function VideoGrid({
if (videos.length === 0) {
return (
<div className="empty-state">
<h2>No videos found</h2>
<p>Try adding some channels from the admin panel</p>
<div className="text-center py-12 px-6 text-muted-foreground">
<h2 className="m-0 mb-2 text-xl font-medium">No videos found</h2>
<p className="m-0 text-sm">Try adding some channels from the admin panel</p>
</div>
);
}
return (
<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 => (
<VideoCard
key={video.id}
@ -73,16 +72,16 @@ export function VideoGrid({ @@ -73,16 +72,16 @@ export function VideoGrid({
</div>
{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
onClick={() => onPageChange(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
</button>
<div className="pagination-numbers">
<div className="flex gap-1 md:flex flex hidden">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
@ -99,7 +98,11 @@ export function VideoGrid({ @@ -99,7 +98,11 @@ export function VideoGrid({
<button
key={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}
</button>
@ -110,7 +113,7 @@ export function VideoGrid({ @@ -110,7 +113,7 @@ export function VideoGrid({
<button
onClick={() => onPageChange(page + 1)}
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
</button>
@ -119,6 +122,3 @@ export function VideoGrid({ @@ -119,6 +122,3 @@ export function VideoGrid({
</div>
);
}

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

@ -1,154 +0,0 @@ @@ -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 @@ @@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react';
import { useTimeLimit } from '../../hooks/useTimeLimit';
import './VideoPlayer.css';
interface VideoPlayerProps {
videoId: string;
@ -69,21 +68,39 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) { @@ -69,21 +68,39 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
};
return (
<div className="modal-overlay" onClick={handleClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="close-button" onClick={handleClose}>×</button>
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-[1000] p-5 md:p-5 p-0"
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 ? (
<div className="time-limit-message">
<h2>Daily Time Limit Reached</h2>
<p>You've reached your daily video watching limit. Come back tomorrow!</p>
<button onClick={handleClose} className="time-limit-button">Close</button>
<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 className="text-[28px] md:text-[28px] text-2xl mb-4 text-[#ff6b6b]">Daily Time Limit Reached</h2>
<p className="text-lg md:text-lg text-base mb-6 opacity-90">
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 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
</div>
<div className="video-container">
<div className="relative w-full pb-[56.25%]">
<iframe
ref={iframeRef}
width="100%"
@ -92,6 +109,7 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) { @@ -92,6 +109,7 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="YouTube video player"
className="absolute top-0 left-0 w-full h-full border-none"
/>
</div>
</>
@ -100,6 +118,3 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) { @@ -100,6 +118,3 @@ export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
</div>
);
}

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

@ -1,371 +0,0 @@ @@ -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 @@ @@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import { wordGroupsApi } from '../../services/apiClient';
import './WordGroupManager.css';
interface Word {
id: number;
@ -163,50 +162,56 @@ export function WordGroupManager() { @@ -163,50 +162,56 @@ export function WordGroupManager() {
if (loading) {
return (
<div className="word-group-manager">
<div className="word-group-header">
<h2>Speech Sounds - Word Groups</h2>
<p>Loading...</p>
<div className="w-full p-6 bg-card rounded-xl border border-border">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Speech Sounds - Word Groups</h2>
<p className="m-0 text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
return (
<div className="word-group-manager">
<div className="word-group-header">
<h2>Speech Sounds - Word Groups</h2>
<p>Create groups of words to help practice speech sounds</p>
<div className="w-full p-6 bg-card rounded-xl border border-border">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Speech Sounds - Word Groups</h2>
<p className="m-0 text-sm text-muted-foreground">Create groups of words to help practice speech sounds</p>
</div>
{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
type="text"
placeholder="Enter group name (e.g., 'R Sounds', 'S Blends')..."
value={newGroupName}
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
</button>
</form>
{groups.length === 0 ? (
<div className="empty-state">
<p>No word groups yet. Create your first group above!</p>
<div className="text-center py-12 px-6 text-muted-foreground">
<p className="m-0 text-sm">No word groups yet. Create your first group above!</p>
</div>
) : (
<div className="groups-list">
<div className="flex flex-col gap-4">
{groups.map(group => (
<div key={group.id} className="word-group-card">
<div className="group-header">
<div key={group.id} className="bg-muted border border-border/50 rounded-lg overflow-hidden">
<div className="p-4 bg-card border-b border-border/50">
{editingGroupId === group.id ? (
<div className="edit-group-form">
<div className="flex gap-2 items-center">
<input
type="text"
value={editingGroupName}
@ -218,40 +223,43 @@ export function WordGroupManager() { @@ -218,40 +223,43 @@ export function WordGroupManager() {
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
/>
<button
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
</button>
<button
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
</button>
</div>
) : (
<>
<div className="group-info">
<h3 className="group-name" onClick={() => toggleGroup(group.id)}>
<div className="flex justify-between items-center md:flex-row flex-col md:items-center items-start gap-3">
<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}
<span className="word-count">({group.wordCount} words)</span>
<span className="text-sm font-normal text-muted-foreground">({group.wordCount} words)</span>
</h3>
<div className="group-actions">
<div className="flex gap-2 md:self-auto self-end">
<button
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"
>
</button>
<button
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"
>
🗑
@ -263,18 +271,23 @@ export function WordGroupManager() { @@ -263,18 +271,23 @@ export function WordGroupManager() {
</div>
{expandedGroups.has(group.id) && (
<div className="group-content">
<div className="words-list">
<div className="p-4">
<div className="mb-4">
{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 => (
<div key={word.id} className="word-item">
<span className="word-text">{word.word}</span>
<div
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
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"
>
×
@ -290,7 +303,7 @@ export function WordGroupManager() { @@ -290,7 +303,7 @@ export function WordGroupManager() {
e.preventDefault();
handleAddWord(group.id);
}}
className="add-word-form"
className="flex gap-2 md:flex-row flex-col"
>
<input
type="text"
@ -303,12 +316,12 @@ export function WordGroupManager() { @@ -303,12 +316,12 @@ export function WordGroupManager() {
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
type="submit"
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
</button>

206
frontend/src/pages/AdminPage.css

@ -1,206 +0,0 @@ @@ -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 @@ @@ -1,30 +1,36 @@
import { Link } from 'react-router-dom';
import './AdminPage.css';
export function AdminPage() {
return (
<div className="admin-page">
<div className="admin-header">
<h1>Admin Dashboard</h1>
<p>Manage app settings and configurations</p>
<div className="min-h-[calc(100vh-60px)] bg-background">
<div className="bg-card border-b border-border py-8 px-6 text-center">
<h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Admin Dashboard</h1>
<p className="m-0 text-sm text-muted-foreground">Manage app settings and configurations</p>
</div>
<div className="admin-links-grid">
<Link to="/admin/videos" className="admin-link-card">
<div className="admin-link-icon">📹</div>
<h2>Video App</h2>
<p>Manage YouTube channels and video time limits</p>
<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="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"
>
<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 to="/admin/speech-sounds" className="admin-link-card">
<div className="admin-link-icon">🗣</div>
<h2>Speech Sounds</h2>
<p>Manage word groups for speech sound practice</p>
<Link
to="/admin/speech-sounds"
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"
>
<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>
</div>
</div>
);
}

143
frontend/src/pages/LandingPage.css

@ -1,143 +0,0 @@ @@ -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 @@ @@ -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;
}

16
frontend/src/pages/SpeechSoundsAdminPage.tsx

@ -1,18 +1,22 @@ @@ -1,18 +1,22 @@
import { WordGroupManager } from '../components/WordGroupManager/WordGroupManager';
import { Link } from 'react-router-dom';
import './AdminPage.css';
export function SpeechSoundsAdminPage() {
return (
<div className="admin-page">
<div className="admin-header">
<Link to="/admin" className="back-button">
<div className="min-h-[calc(100vh-60px)] bg-background">
<div className="bg-card border-b border-border py-8 px-6 text-center">
<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
</Link>
<h1>Speech Sounds - Word Groups</h1>
<p>Manage word groups for speech sound practice</p>
<h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Speech Sounds - Word Groups</h1>
<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>
);
}

13
frontend/src/pages/VideoApp.css

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

32
frontend/src/pages/VideosAdminPage.tsx

@ -3,7 +3,6 @@ import { Link } from 'react-router-dom'; @@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import { ChannelManager } from '../components/ChannelManager/ChannelManager';
import { TimeLimitManager } from '../components/TimeLimitManager/TimeLimitManager';
import { videosApi } from '../services/apiClient';
import './AdminPage.css';
export function VideosAdminPage() {
const [refreshing, setRefreshing] = useState(false);
@ -29,35 +28,42 @@ export function VideosAdminPage() { @@ -29,35 +28,42 @@ export function VideosAdminPage() {
};
return (
<div className="admin-page">
<div className="admin-header">
<Link to="/admin" className="back-button">
<div className="min-h-[calc(100vh-60px)] bg-background">
<div className="bg-card border-b border-border py-8 px-6 text-center">
<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
</Link>
<h1>Video App Settings</h1>
<p>Manage YouTube channels and video time limits</p>
<div style={{ marginTop: '16px' }}>
<h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Video App Settings</h1>
<p className="m-0 text-sm text-muted-foreground">Manage YouTube channels and video time limits</p>
<div className="mt-4">
<button
onClick={handleRefreshVideos}
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'}
</button>
{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 && (
<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 className="admin-content">
<div className="admin-column">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6 max-w-[1600px] mx-auto">
<div className="flex flex-col">
<ChannelManager />
</div>
<div className="admin-column">
<div className="flex flex-col">
<TimeLimitManager />
</div>
</div>

Loading…
Cancel
Save