diff --git a/config/apache/rewrites/users.conf b/config/apache/rewrites/users.conf index 5a315f52..2cdbae86 100644 --- a/config/apache/rewrites/users.conf +++ b/config/apache/rewrites/users.conf @@ -3,4 +3,6 @@ RewriteRule ^/users/([\d]+)$ /users/post.php?user-id=$1 [L] RewriteRule ^/users/([^/]+)$ /users/get.php?user-identifier=$1 [B,L] -RewriteRule ^/users/([\d]+)/edit$ /users/edit.php?user-id=$1 [L] +RewriteRule ^/users/([^/]+)/edit$ /users/edit.php?user-identifier=$1 [L] + +RewriteRule ^/users/([^/]+)/projects$ /users/projects/index.php?user-identifier=$1 [L] diff --git a/config/sql/se/Benefits.sql b/config/sql/se/Benefits.sql index 0837a98a..ff881b4a 100644 --- a/config/sql/se/Benefits.sql +++ b/config/sql/se/Benefits.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS `Benefits` ( `CanEditUsers` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanEditCollections` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanEditEbooks` tinyint(1) unsigned NOT NULL DEFAULT 0, - `CanCreateEbookPlaceholders` tinyint(1) unsigned NOT NULL DEFAULT 0, + `CanEditEbookPlaceholders` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanManageProjects` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanReviewProjects` tinyint(1) unsigned NOT NULL DEFAULT 0, `CanEditProjects` tinyint(1) unsigned NOT NULL DEFAULT 0, diff --git a/lib/Benefits.php b/lib/Benefits.php index ca566a09..52d05c6c 100644 --- a/lib/Benefits.php +++ b/lib/Benefits.php @@ -17,7 +17,7 @@ class Benefits{ public bool $CanEditUsers = false; public bool $CanEditCollections = false; public bool $CanEditEbooks = false; - public bool $CanCreateEbookPlaceholders = false; + public bool $CanEditEbookPlaceholders = false; public bool $CanManageProjects = false; public bool $CanReviewProjects = false; public bool $CanEditProjects = false; @@ -38,7 +38,7 @@ class Benefits{ || $this->CanEditEbooks || - $this->CanCreateEbookPlaceholders + $this->CanEditEbookPlaceholders || $this->CanManageProjects || @@ -76,18 +76,18 @@ class Benefits{ public function Create(): void{ Db::Query(' - INSERT into Benefits (UserId, CanAccessFeeds, CanVote, CanBulkDownload, CanUploadArtwork, CanReviewArtwork, CanReviewOwnArtwork, CanEditUsers, CanCreateEbookPlaceholders) + INSERT into Benefits (UserId, CanAccessFeeds, CanVote, CanBulkDownload, CanUploadArtwork, CanReviewArtwork, CanReviewOwnArtwork, CanEditUsers, CanEditEbookPlaceholders) values (?, ?, ?, ?, ?, ?, ?, ?, ?) - ', [$this->UserId, $this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->CanCreateEbookPlaceholders]); + ', [$this->UserId, $this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->CanEditEbookPlaceholders]); } public function Save(): void{ Db::Query(' UPDATE Benefits - set CanAccessFeeds = ?, CanVote = ?, CanBulkDownload = ?, CanUploadArtwork = ?, CanReviewArtwork = ?, CanReviewOwnArtwork = ?, CanEditUsers = ?, CanCreateEbookPlaceholders = ? + set CanAccessFeeds = ?, CanVote = ?, CanBulkDownload = ?, CanUploadArtwork = ?, CanReviewArtwork = ?, CanReviewOwnArtwork = ?, CanEditUsers = ?, CanEditEbookPlaceholders = ? where UserId = ? - ', [$this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->CanCreateEbookPlaceholders, $this->UserId]); + ', [$this->CanAccessFeeds, $this->CanVote, $this->CanBulkDownload, $this->CanUploadArtwork, $this->CanReviewArtwork, $this->CanReviewOwnArtwork, $this->CanEditUsers, $this->CanEditEbookPlaceholders, $this->UserId]); } public function FillFromHttpPost(): void{ @@ -98,6 +98,9 @@ class Benefits{ $this->PropertyFromHttp('CanReviewArtwork'); $this->PropertyFromHttp('CanReviewOwnArtwork'); $this->PropertyFromHttp('CanEditUsers'); - $this->PropertyFromHttp('CanCreateEbookPlaceholders'); + $this->PropertyFromHttp('CanEditEbookPlaceholders'); + $this->PropertyFromHttp('CanEditProjects'); + $this->PropertyFromHttp('CanReviewProjects'); + $this->PropertyFromHttp('CanManageProjects'); } } diff --git a/lib/Project.php b/lib/Project.php index 27056b5a..c7424643 100644 --- a/lib/Project.php +++ b/lib/Project.php @@ -15,6 +15,7 @@ use Safe\DateTimeImmutable; * @property User $ManagerUser * @property User $ReviewerUser * @property string $Url + * @property DateTimeImmutable $LastActivityTimestamp The timestamp of the latest activity, whether it's a commit, a discussion post, or simply the started timestamp. */ class Project{ use Traits\Accessor; @@ -40,6 +41,7 @@ class Project{ protected User $_ManagerUser; protected User $_ReviewerUser; protected string $_Url; + protected DateTimeImmutable $_LastActivityTimestamp; // ******* @@ -54,6 +56,22 @@ class Project{ return $this->_Url; } + protected function GetLastActivityTimestamp(): DateTimeImmutable{ + if(!isset($this->_LastActivityTimestamp)){ + $dates = [ + (int)($this->LastCommitTimestamp?->format(Enums\DateTimeFormat::UnixTimestamp->value) ?? 0) => $this->LastCommitTimestamp ?? NOW, + (int)($this->LastDiscussionTimestamp?->format(Enums\DateTimeFormat::UnixTimestamp->value) ?? 0) => $this->LastDiscussionTimestamp ?? NOW, + (int)($this->Started->format(Enums\DateTimeFormat::UnixTimestamp->value)) => $this->Started, + ]; + + ksort($dates); + + $this->_LastActivityTimestamp = end($dates); + } + + return $this->_LastActivityTimestamp; + } + // ******* // METHODS @@ -96,6 +114,12 @@ class Project{ if($this->DiscussionUrl == ''){ $this->DiscussionUrl = null; } + else{ + if(preg_match('|^https://groups\.google\.com/g/standardebooks/|iu', $this->DiscussionUrl)){ + // Get the base thread URL in case we were passed a URL with a specific message or query string. + $this->DiscussionUrl = preg_replace('|^(https://groups\.google\.com/g/standardebooks/c/[^/]+).*|iu', '\1', $this->DiscussionUrl); + } + } $this->VcsUrl = rtrim(trim($this->VcsUrl ?? ''), '/'); if($this->VcsUrl == ''){ @@ -268,6 +292,10 @@ class Project{ * @throws Exceptions\AppException If the operation failed. */ public function FetchLatestCommitTimestamp(?string $apiKey = null): void{ + if(!preg_match('|^https://github\.com/|iu', $this->VcsUrl)){ + return; + } + $headers = [ 'Accept: application/vnd.github+json', 'X-GitHub-Api-Version: 2022-11-28', @@ -327,7 +355,7 @@ class Project{ * @throws Exceptions\AppException If the operation faile.d */ public function FetchLastDiscussionTimestamp(): void{ - if($this->DiscussionUrl === null){ + if(!preg_match('|^https://groups\.google\.com/g/standardebooks/|iu', $this->DiscussionUrl ?? '')){ return; } @@ -347,7 +375,7 @@ class Project{ throw new Exception('HTTP code ' . $httpCode . ' received for URL <' . $this->DiscussionUrl . '>.'); } - $matchCount = preg_match_all('/([a-z]{3} [\d]{1,2}, [\d]{4}, [\d]{2}:[\d]{2}:[\d]{2} (?:AM|PM))<\/span>/iu', $response, $matches); + $matchCount = preg_match_all('/([a-z]{3} [\d]{1,2}, [\d]{4}, [\d]{1,2}:[\d]{1,2}:[\d]{1,2} (?:AM|PM))/iu', $response, $matches); if($matchCount > 0){ // Unsure of the time zone, so just assume UTC. @@ -390,4 +418,18 @@ class Project{ public static function GetAllByStatus(Enums\ProjectStatusType $status): array{ return Db::Query('SELECT * from Projects where Status = ? order by Started desc', [$status], Project::class); } + + /** + * @return array + */ + public static function GetAllByManagerUserId(int $userId): array{ + return Db::Query('SELECT * from Projects where ManagerUserId = ? and Status in (?, ?) order by Started desc', [$userId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled], Project::class); + } + + /** + * @return array + */ + public static function GetAllByReviewerUserId(int $userId): array{ + return Db::Query('SELECT * from Projects where ReviewerUserId = ? and Status in (?, ?) order by Started desc', [$userId, Enums\ProjectStatusType::InProgress, Enums\ProjectStatusType::Stalled], Project::class); + } } diff --git a/lib/User.php b/lib/User.php index 2cc6f5fa..778ca0c8 100644 --- a/lib/User.php +++ b/lib/User.php @@ -12,6 +12,7 @@ use function Safe\preg_match; * @property ?Patron $Patron * @property ?NewsletterSubscription $NewsletterSubscription * @property ?Payment $LastPayment + * @property string $DisplayName A string that represent's the `User`'s name, or email, or ID. */ class User{ use Traits\Accessor; @@ -33,12 +34,29 @@ class User{ protected string $_Url; protected ?Patron $_Patron; protected ?NewsletterSubscription $_NewsletterSubscription; + protected string $_DisplayName; // ******* // GETTERS // ******* + protected function GetDisplayName(): string{ + if(!isset($this->_DisplayName)){ + if($this->Name !== null){ + $this->_DisplayName = $this->Name; + } + elseif($this->Email !== null){ + $this->_DisplayName = $this->Email; + } + else{ + $this->_DisplayName = 'User #' . $this->UserId; + } + } + + return $this->_DisplayName; + } + protected function GetNewsletterSubscription(): ?NewsletterSubscription{ if(!isset($this->_NewsletterSubscription)){ try{ diff --git a/scripts/update-project-statuses b/scripts/update-project-statuses index d9f674f2..12403a14 100755 --- a/scripts/update-project-statuses +++ b/scripts/update-project-statuses @@ -3,17 +3,12 @@ require_once('/standardebooks.org/web/lib/Core.php'); use function Safe\file_get_contents; +use Safe\DateTimeImmutable; /** * Iterate over all `Project`s that are in progress or stalled and get their latest GitHub commit. If the commit is more than 30 days old, mark the `Project` as stalled. */ - - -use Safe\DateTimeImmutable; - - - $projects = array_merge( Project::GetAllByStatus(Enums\ProjectStatusType::InProgress), Project::GetAllByStatus(Enums\ProjectStatusType::Stalled) diff --git a/templates/EbookMetadata.php b/templates/EbookMetadata.php index a02e11b5..5a3941ad 100644 --- a/templates/EbookMetadata.php +++ b/templates/EbookMetadata.php @@ -3,33 +3,30 @@ * @var Ebook $ebook */ ?> -

Metadata

- - - - - - - IsPlaceholder() && $ebook->EbookPlaceholder !== null){ ?> +
+

Metadata

+
Ebook ID:EbookId ?>
+ - - + + - EbookPlaceholder->IsWanted){ ?> + IsPlaceholder() && $ebook->EbookPlaceholder !== null){ ?> - - + + + + EbookPlaceholder->IsWanted){ ?> + + + + + + + + - - - - - - -
Is wanted:EbookPlaceholder->IsWanted){ ?>☑Ebook ID:EbookId ?>
Is Patron selection:EbookPlaceholder->IsPatron){ ?>☑Is wanted:EbookPlaceholder->IsWanted){ ?>☑
Is Patron selection:EbookPlaceholder->IsPatron){ ?>☑
Difficulty:EbookPlaceholder->Difficulty->value ?? '') ?>
Difficulty:EbookPlaceholder->Difficulty->value ?? '') ?>
- -Projects) > 0){ ?> -

Projects

- $ebook->Projects, 'includeTitle' => false]) ?> - + + + diff --git a/templates/EbookProjects.php b/templates/EbookProjects.php new file mode 100644 index 00000000..9e8f1da6 --- /dev/null +++ b/templates/EbookProjects.php @@ -0,0 +1,11 @@ + +Projects) > 0){ ?> +
+

Projects

+ $ebook->Projects, 'includeTitle' => false]) ?> +
+ diff --git a/templates/ProjectsTable.php b/templates/ProjectsTable.php index f83406cf..4ee7bbeb 100644 --- a/templates/ProjectsTable.php +++ b/templates/ProjectsTable.php @@ -14,8 +14,10 @@ $includeStatus = $includeStatus ?? true; Title Producer + Manager + Reviewer Started - Last commit + Last activity Status @@ -38,14 +40,20 @@ $includeStatus = $includeStatus ?? true; ProducerName) ?> + + ManagerUser->DisplayName) ?> + + + ReviewerUser->DisplayName) ?> + Started->format(Enums\DateTimeFormat::ShortDate->value) ?> - LastCommitTimestamp?->format(Enums\DateTimeFormat::ShortDate->value) ?> + LastActivityTimestamp->format(Enums\DateTimeFormat::ShortDate->value) ?> - + Status->GetDisplayName()) ?> diff --git a/templates/UserForm.php b/templates/UserForm.php index 8168a207..89de16a9 100644 --- a/templates/UserForm.php +++ b/templates/UserForm.php @@ -137,9 +137,30 @@ $passwordAction = $passwordAction ?? Enums\PasswordActionType::None;
  • +
  • +
  • + +
  • +
  • + +
  • +
  • +
  • diff --git a/www/css/core.css b/www/css/core.css index 454ea016..87bd81f1 100644 --- a/www/css/core.css +++ b/www/css/core.css @@ -749,6 +749,16 @@ ul.message.error > li + li{ padding-top: 2rem; } +.data-table td:first-child, +.data-table th:first-child{ + padding-left: 0; +} + +.data-table td:last-child, +.data-table th:last-child{ + padding-left: 0; +} + .data-table td, .data-table th{ padding: .25rem .5rem; @@ -3293,6 +3303,20 @@ table.admin-table td + td{ width: 100%; } +.empty-notice{ + text-align: center; + font-style: italic; +} + +nav.breadcrumbs{ + font-style: italic; + margin-top: 1rem; +} + +nav.breadcrumbs + h1{ + margin-top: 0; +} + @media (hover: none) and (pointer: coarse){ /* target ipads and smartphones without a mouse */ /* For iPad, unset the height so it matches the other elements */ select[multiple]{ diff --git a/www/css/project.css b/www/css/project.css index 460df40f..5f8ddd18 100644 --- a/www/css/project.css +++ b/www/css/project.css @@ -12,15 +12,16 @@ grid-column-start: 1; } -h2 + table.projects{ - margin-top: 2rem; -} - -table.projects thead{ - font-style: italic; -} - table.data-table .status, table.data-table .producer{ white-space: nowrap; } + +table.data-table{ + width: 100%; +} + +table.data-table td.status.stalled{ + background: #861d1d !important; /* Override hover backgound color */ + color: #ffffff; +} diff --git a/www/ebook-placeholders/get.php b/www/ebook-placeholders/get.php index c96be037..658d7f6d 100644 --- a/www/ebook-placeholders/get.php +++ b/www/ebook-placeholders/get.php @@ -104,9 +104,11 @@ catch(Exceptions\EbookNotFoundException){ Benefits->CanEditEbooks){ ?> -
    - $ebook]) ?> -
    + $ebook]) ?> + + + Benefits->CanEditProjects || Session::$User?->Benefits->CanManageProjects || Session::$User?->Benefits->CanReviewProjects){ ?> + $ebook]) ?> diff --git a/www/ebook-placeholders/new.php b/www/ebook-placeholders/new.php index f04e0d9f..820bfe9d 100644 --- a/www/ebook-placeholders/new.php +++ b/www/ebook-placeholders/new.php @@ -12,7 +12,7 @@ try{ throw new Exceptions\LoginRequiredException(); } - if(!Session::$User->Benefits->CanCreateEbookPlaceholders){ + if(!Session::$User->Benefits->CanEditEbookPlaceholders){ throw new Exceptions\InvalidPermissionsException(); } diff --git a/www/ebook-placeholders/post.php b/www/ebook-placeholders/post.php index 2ca57f26..f60347dd 100644 --- a/www/ebook-placeholders/post.php +++ b/www/ebook-placeholders/post.php @@ -11,7 +11,7 @@ try{ // POSTing a new ebook placeholder. if($httpMethod == Enums\HttpMethod::Post){ - if(!Session::$User->Benefits->CanCreateEbookPlaceholders){ + if(!Session::$User->Benefits->CanEditEbookPlaceholders){ throw new Exceptions\InvalidPermissionsException(); } diff --git a/www/ebooks/get.php b/www/ebooks/get.php index 1851b723..1430eb7b 100644 --- a/www/ebooks/get.php +++ b/www/ebooks/get.php @@ -398,9 +398,7 @@ catch(Exceptions\EbookNotFoundException){ Benefits->CanEditEbooks){ ?> -
    - $ebook]) ?> -
    + $ebook]) ?> 0){ ?> diff --git a/www/projects/index.php b/www/projects/index.php index 35ff86aa..9980f981 100644 --- a/www/projects/index.php +++ b/www/projects/index.php @@ -4,7 +4,13 @@ try{ throw new Exceptions\LoginRequiredException(); } - if(!Session::$User->Benefits->CanEditProjects){ + if( + !Session::$User->Benefits->CanManageProjects + && + !Session::$User->Benefits->CanReviewProjects + && + !Session::$User->Benefits->CanEditProjects + ){ throw new Exceptions\InvalidPermissionsException(); } @@ -17,22 +23,28 @@ catch(Exceptions\LoginRequiredException){ catch(Exceptions\InvalidPermissionsException){ Template::Emit403(); } -?> 'Projects', 'css' => ['/css/project.css'], 'description' => 'Ebook projects currently underway at Standard Ebooks.']) ?> +?> 'Projects', + 'css' => ['/css/project.css'], + 'description' => 'Ebook projects currently underway at Standard Ebooks.' + ]) ?>
    -
    +

    Projects

    -

    Active projects

    - -

    - None. -

    - - $inProgressProjects, 'includeStatus' => false]) ?> - +
    +

    Active projects

    + +

    None.

    + + $inProgressProjects, 'includeStatus' => false]) ?> + +
    0){ ?> -

    Stalled projects

    - $stalledProjects, 'includeStatus' => false]) ?> +
    +

    Stalled projects

    + $stalledProjects, 'includeStatus' => false]) ?> +
    diff --git a/www/users/edit.php b/www/users/edit.php index ef6d1243..84ef1f49 100644 --- a/www/users/edit.php +++ b/www/users/edit.php @@ -10,7 +10,7 @@ $passwordAction = HttpInput::SessionObject('password-action', Enums\PasswordActi try{ if($user === null){ - $user = User::Get(HttpInput::Int(GET, 'user-id')); + $user = User::GetByIdentifier(HttpInput::Str(GET, 'user-identifier')); } if(Session::$User === null){ @@ -40,13 +40,14 @@ catch(Exceptions\InvalidPermissionsException){ 'Edit user #' . $user->UserId, + 'canonicalUrl' => $user->Url . '/edit', 'css' => ['/css/user.css'], 'highlight' => '' ] ) ?>
    -

    Edit User #UserId ?>

    +

    Edit DisplayName) ?>

    $exception]) ?> diff --git a/www/users/get.php b/www/users/get.php index eea72da4..699ed4b5 100644 --- a/www/users/get.php +++ b/www/users/get.php @@ -8,11 +8,6 @@ $isSaved = HttpInput::Bool(SESSION, 'is-user-saved') ?? false; try{ $user = User::GetByIdentifier(HttpInput::Str(GET, 'user-identifier')); - // Even though the identifier can be either an email, user ID, or UUID, we want the URL of this page to be based on a user ID only. - if(!ctype_digit(HttpInput::Str(GET, 'user-identifier'))){ - throw new Exceptions\SeeOtherException($user->Url); - } - if(Session::$User === null){ throw new Exceptions\LoginRequiredException(); } @@ -35,15 +30,16 @@ catch(Exceptions\LoginRequiredException){ catch(Exceptions\InvalidPermissionsException){ Template::Emit403(); } -catch(Exceptions\SeeOtherException $ex){ - http_response_code(Enums\HttpCode::SeeOther->value); - header('Location: ' . $ex->Url); -} - -?> 'User #' . $user->UserId, 'css' => ['/css/user.css']]) ?> +?> $user->DisplayName, + 'canonicalUrl' => $user->Url, + 'css' => ['/css/user.css'] + ] +) ?>
    -

    User #UserId ?>

    +

    DisplayName) ?>

    User saved!

    @@ -51,9 +47,17 @@ catch(Exceptions\SeeOtherException $ex){ Edit user + Benefits->CanManageProjects || $user->Benefits->CanReviewProjects){ ?> + Projects + +

    Basics

    + + + + @@ -170,8 +174,20 @@ catch(Exceptions\SeeOtherException $ex){ - - + + + + + + + + + + + + + + @@ -179,7 +195,7 @@ catch(Exceptions\SeeOtherException $ex){

    Payments

    Payments) == 0){ ?> -

    None.

    +

    None.

    View all payments at Fractured Atlas diff --git a/www/users/projects/index.php b/www/users/projects/index.php new file mode 100644 index 00000000..a7afe374 --- /dev/null +++ b/www/users/projects/index.php @@ -0,0 +1,62 @@ +Benefits->CanManageProjects + && + !Session::$User->Benefits->CanReviewProjects + && + !Session::$User->Benefits->CanEditProjects + ){ + throw new Exceptions\InvalidPermissionsException(); + } + + $managingProjects = Project::GetAllByManagerUserId($user->UserId); + $reviewingProjects = Project::GetAllByReviewerUserId($user->UserId); +} +catch(Exceptions\UserNotFoundException){ + Template::Emit404(); +} +catch(Exceptions\LoginRequiredException){ + Template::RedirectToLogin(); +} +catch(Exceptions\InvalidPermissionsException){ + Template::Emit403(); +} +?> 'Projects', + 'canonicalUrl' => $user->Url, + 'css' => ['/css/project.css'], + 'description' => 'Ebook projects currently underway at Standard Ebooks.' + ] +) ?> +

    +
    + +

    Projects

    +
    +

    Managing

    + +

    None.

    + + $managingProjects]) ?> + +
    + +
    +

    Reviewing

    + +

    None.

    + + $reviewingProjects]) ?> + +
    +
    +
    +
    User ID:UserId ?>
    Email: Email) ?>Benefits->CanEditUsers){ ?>☑
    Can create ebook placeholders:Benefits->CanCreateEbookPlaceholders){ ?>☑Can edit ebook placeholders:Benefits->CanEditEbookPlaceholders){ ?>☑
    Can edit projects:Benefits->CanEditProjects){ ?>☑
    Can manage projects:Benefits->CanManageProjects){ ?>☑
    Can review projects:Benefits->CanReviewProjects){ ?>☑