Moved css to separate file + Wrote minimal issue tracker for ncdc

I designed the issue tracker to be a separate module for use in TUWF
websites, and perhaps even as standalone. But for now it's just a small
file in this repository.
This commit is contained in:
Yorhel 2012-03-17 17:43:20 +01:00
parent ec64b3fa9d
commit 605b0643d3
3 changed files with 447 additions and 69 deletions

282
Issue.pm Normal file
View file

@ -0,0 +1,282 @@
=head1 SQL Schema
CREATE TABLE ${p}issues (
issue SERIAL PRIMARY KEY,
latest integer NOT NULL
);
CREATE TABLE ${p}messages (
id SERIAL PRIMARY KEY,
issue integer NOT NULL,
date timestamptz NOT NULL DEFAULT NOW(),
summary varchar(200) NOT NULL,
type varchar NOT NULL DEFAULT '',
status varchar NOT NULL DEFAULT '',
closed boolean NOT NULL DEFAULT false,
email varchar NOT NULL DEFAULT '',
message varchar NOT NULL DEFAULT ''
);
=cut
# TODO: Atom feed?
package TUWF::Issue;
use TUWF ':html', 'html_escape';
sub new {
my $class = shift;
return bless {
prefix => 'issue_',
types => [qw|bug feature docs other|],
default_type => 'other',
statusses => [qw|new accepted duplicate confirmed fixed wontfix worksforme|],
default_status => 'new',
admins => ['code1', 'code2'],
@_
}, $class;
}
sub dbListing {
my $self = shift;
my %o = (
results => 100,
page => 1,
);
$o{reverse} = 1 if !$o{sort};
my %where = (
$o{id} ? ('i.issue = ?' => $o{id}) : (),
);
my $order = sprintf {
date => 'm.id %s',
}->{$o{sort}||'date'}, $o{reverse} ? 'DESC' : 'ASC';
my($r, $np) = $TUWF::OBJ->dbPage(\%o, q{
SELECT i.issue, m.summary, to_char(m.date, 'YYYY-MM-DD') AS date, m.type, m.status, m.closed
FROM !sissues i
JOIN !smessages m ON m.id = i.latest
!W
ORDER BY !s}, $self->{prefix}, $self->{prefix}, \%where, $order
);
return wantarray ? ($r, $np) : $r;
}
sub dbItem {
my($self, $id) = @_;
return $TUWF::OBJ->dbAll(q{
SELECT m.issue, m.summary, to_char(m.date, 'YYYY-MM-DD HH24:MI:SS (tz)') AS date, m.type, m.status, m.closed, m.message
FROM !smessages m
WHERE m.issue = ?
ORDER BY m.id}, $self->{prefix}, $id
);
}
sub dbEmails {
my($self, $id) = @_;
return [ map $_->{email}, @{$TUWF::OBJ->dbAll(q|SELECT DISTINCT m.email FROM !smessages m WHERE m.issue = ? AND m.email <> ''|, $self->{prefix}, $id)} ];
}
sub dbSave {
my($self, $id, $closed, @a) = @_;
$id = $TUWF::OBJ->dbRow('INSERT INTO !sissues (latest) VALUES (0) RETURNING issue', $self->{prefix})->{issue} if !$id;
my $latest = $TUWF::OBJ->dbRow(
'INSERT INTO !smessages (issue, closed, summary, email, type, status, message) VALUES (?, ?, !l) RETURNING id',
$self->{prefix}, $id, $closed?1:0, \@a
)->{id};
$TUWF::OBJ->dbExec('UPDATE !sissues SET latest = ? WHERE issue = ?', $self->{prefix}, $latest, $id);
return $id;
}
# TODO: pagination / filtering
sub htmlListing {
my($s, $l, $lnk) = @_;
table class => 'issue_listing';
thead; Tr;
td class => 'issue_col_id', 'Id';
td class => 'issue_col_type', 'Type';
td class => 'issue_col_status', 'Status';
td class => 'issue_col_date', 'Updated';
td class => 'issue_col_summary','Summary';
end; end;
for(@$l) {
Tr $_->{closed} ? (class => 'issue_closed') : ();
td class => 'issue_col_id', $_->{issue};
td class => 'issue_col_type', $_->{type};
td class => 'issue_col_status', $_->{status};
td class => 'issue_col_date', $_->{date};
td class => 'issue_col_summary';
a href => $lnk->($_->{issue}), $_->{summary};
end;
end;
}
end;
}
sub htmlItem {
my($s, $d) = @_;
my $last = $d->[$#$d];
dl class => 'issue_status';
dt 'Id'; dd $last->{issue};
dt 'Messages'; dd $#$d+1;
dt 'Type'; dd $last->{type};
dt 'Status'; dd $last->{status};
end;
div class => 'issue_item';
my $num = -1;
for my $m (@$d) {
div class => 'issue_message';
h1 !++$num ? 'Description' : "Reply $num";
dl;
dt !$num ? 'Created' : 'Added'; dd $m->{date};
for($num ? qw|summary type status| : ()) {
if($m->{$_} ne $d->[$num-1]{$_}) {
dt "\u$_";
dd sprintf '"%s" to "%s"', $d->[$num-1]{$_}, $m->{$_};
}
}
if($num && !$m->{closed} != !$d->[$num-1]{$_}) {
dt "Closed";
dd sprintf '"%s" to "%s"', $d->[$num-1]{closed}?'yes':'no', $m->{closed}?'yes':'no';
}
end;
# TODO: link formatting and some way to link to other issues
p; lit html_escape $m->{message}; end;
end;
}
end;
}
sub htmlForm {
my($s, $l, $url) = @_;
# TODO: anti-spam JS
form class => 'issue_frm', action => $url, method => 'post';
fieldset;
input type => 'hidden', name => 'issue_id', value => $l ? $l->{issue} : 0;
legend $l ? 'Reply' : 'Report a new issue';
ul;
li class => 'issue_frm_summary';
label for => 'issue_summary', 'Summary';
input type => 'text', name => 'issue_summary', id => 'issue_summary', size => 45, value => $l?$l->{summary}:'';
end;
li class => 'issue_frm_mail';
label for => 'issue_email', 'Email';
input type => 'text', name => 'issue_email', id => 'issue_email', size => 20;
lit '&nbsp;';
txt 'Optional, only used for notifications.';
end;
if($l) {
li class => 'issue_frm_admin';
label for => 'issue_type', 'Admin';
Select name => 'issue_type';
option value => $_, $_ eq $l->{type} ? (selected => 'selected') : (), $_ for @{$s->{types}};
end;
Select name => 'issue_status';
option value => $_, $_ eq $l->{status} ? (selected => 'selected') : (), $_ for @{$s->{statusses}};
end;
Select name => 'issue_closed';
option value => 0, !$l->{closed} ? (selected => 'selected') : (), 'Open';
option value => 1, $l->{closed} ? (selected => 'selected') : (), 'Closed';
end;
input type => 'password', name => 'issue_code', id => 'issue_code', size => 10, value => 'code';
end;
} else {
li class => 'issue_frm_type';
label for => 'issue_type', 'Type';
Select name => 'issue_type';
option value => $_, $_ eq $s->{default_type} ? (selected => 'selected') : (), $_ for @{$s->{types}};
end;
end;
}
li class => 'issue_frm_message';
textarea name => 'issue_message';end; br;
lit 'Please use a <a href="http://p.blicky.net/">pastebin</a> if you want to include large chunks of code or program output.';
end;
li class => 'issue_frm_submit';
input type => 'submit', value => 'Submit';
end;
end 'ul';
end;
end 'form';
}
sub handleForm {
my($s, $url) = @_;
my $f = $TUWF::OBJ->formValidate(
{ post => 'issue_id', min => 0 },
{ post => 'issue_summary', maxlength => 200, minlength => 2 },
{ post => 'issue_email', required => 0, regex => qr/^[^@<>]+@[^@.<>]+(?:\.[^@.<>]+)+$/ },
{ post => 'issue_code', required => 0, default => '' },
{ post => 'issue_message', maxlength => 256*1024, minlength => 1 },
);
return($f, undef) if $f->{_err};
my $l;
# Reply
if($f->{issue_id} > 0) {
$l = $s->dbListing(id => $f->{issue_id})->[0];
push @{$f->{_err}}, ['issue_id', 'db_check', ''] and return($f, undef) if !$l;
# Check admin things
if(grep $_ eq $f->{issue_code}, @{$s->{admins}}) {
my $fa = $TUWF::OBJ->formValidate(
{ post => 'issue_type', enum => $s->{types} },
{ post => 'issue_status', enum => $s->{statusses} },
{ post => 'issue_closed', enum => [0,1] },
);
$f = { %$f, %$fa };
return($f, $l) if $f->{_err};
} else {
$f->{issue_type} = $l->{type};
$f->{issue_status} = $l->{status};
$f->{issue_closed} = $l->{closed};
}
# New issue
} else {
$f->{issue_status} = $s->{default_status};
$f->{issue_closed} = 0;
my $fa = $TUWF::OBJ->formValidate({ post => 'issue_type', enum => $s->{types} });
$f = { %$f, %$fa };
return($f, $l) if $f->{_err};
}
# No errors? Save!
my $id = $s->dbSave(map $f->{"issue_$_"}, qw|id closed summary email type status message|);
# For replies, send out notification emails
if($l) {
my $mails = $s->dbEmails($id);
my $u = $url->($id);
for(grep $_ ne $f->{issue_email}, @$mails) {
$TUWF::OBJ->mail(
"Hello!\n\n".
"A new reply has been posted to an issue you have previously shown\n".
"an interest in. You can view the reply at the following URL:\n".
" $u\n\n".
"If you do not wish to receive any more notifications for this (and\n".
"perhaps other) issues, please reply to this email stating your intent.",
Subject => "Reply to $f->{issue_summary}",
To => "$_",
);
}
}
$l = $s->dbListing(id => $id)->[0] if !$l;
return($f, $l);
}
1;

147
index.cgi
View file

@ -12,6 +12,7 @@ BEGIN { ($ROOT = abs_path $0) =~ s{index\.cgi$}{}; }
my @changes = ( my @changes = (
[ '2012-03-17', '/ncdc/issue', 'Wrote a small issue tracker for ncdc' ],
[ '2012-03-14', '/ncdc', 'ncdc 1.9 released.' ], [ '2012-03-14', '/ncdc', 'ncdc 1.9 released.' ],
[ '2012-02-15', '/doc/commvis', 'Added an article on my new communication system.' ], [ '2012-02-15', '/doc/commvis', 'Added an article on my new communication system.' ],
[ '2012-02-13', '/ncdc', 'ncdc 1.8 released.' ], [ '2012-02-13', '/ncdc', 'ncdc 1.8 released.' ],
@ -74,11 +75,16 @@ TUWF::register(
qr{dump/grenamr} => sub { podpage(shift, 'dump-grenamr', 'dump', 'grenamr', 'GTK+ Mass File Renamer') }, qr{dump/grenamr} => sub { podpage(shift, 'dump-grenamr', 'dump', 'grenamr', 'GTK+ Mass File Renamer') },
qr{dump/nccolour} => sub { podpage(shift, 'dump-nccolour', 'dump', 'nccolour', 'Colours in NCurses') }, qr{dump/nccolour} => sub { podpage(shift, 'dump-nccolour', 'dump', 'nccolour', 'Colours in NCurses') },
qr{feed\.atom} => \&atom, qr{feed\.atom} => \&atom,
qr{(ncdc)/issue} => \&issue_list,
qr{(ncdc)/issue/post} => \&issue_post,
qr{(ncdc)/issue/new} => \&issue_new,
qr{(ncdc)/issue/([1-9][0-9]*)} => \&issue_item,
); );
TUWF::set( TUWF::set(
logfile => '/www/err.log', logfile => '/www/err.log',
error_404_handler => \&notfound, error_404_handler => \&notfound,
mail_from => 'Yorhel\'s issue tracker <projects@yorhel.nl>',
# this is a fairly static site, allow some aggressive caching # this is a fairly static site, allow some aggressive caching
pre_request_handler => sub { $_[0]->resHeader('Cache-Control', 's-max-age=86400, max-age=3600'); 1; }, pre_request_handler => sub { $_[0]->resHeader('Cache-Control', 's-max-age=86400, max-age=3600'); 1; },
); );
@ -190,6 +196,76 @@ sub notfound {
# Issue handling
sub _issue_init {
require "$ROOT/Issue.pm";
my($s, $p) = @_;
$s->resHeader('Cache-Control', 'no-cache');
$s->_load_module('TUWF::DB');
$s->{_TUWF}{db_login} = [ undef, undef, undef ];
$s->dbInit;
return TUWF::Issue->new(prefix => $p.'_', admins => [ $ENV{ISSUE_CODE} ]);
}
sub issue_list {
my($s, $p) = @_;
my $is = _issue_init(@_);
$s->htmlHeader(title => 'Ncdc Issue tracker', page => $p, sec => 'issue');
br; a href => "/$p/issue/new", 'Report new issue'; br; br;
$is->htmlListing((scalar $is->dbListing()), sub { "/$p/issue/".shift });
br; a href => "/$p/issue/new", 'Report new issue'; br; br;
$s->htmlFooter;
}
sub issue_new {
my($s, $p) = @_;
my $is = _issue_init(@_);
$s->htmlHeader(title => 'Ncdc: Report new issue', page => $p, sec => 'issue');
br; a href => "/$p/issue", 'Back to the issue index'; br; br;
$is->htmlForm(undef, "/$p/issue/post");
$s->htmlFooter;
}
sub issue_post {
my($s, $p) = @_;
return $s->resNotFound if $s->reqMethod() ne 'POST';
my $is = _issue_init($s, $p);
my($f, $l) = $is->handleForm(sub { "http://dev.yorhel.nl/$p/issue/".shift });
if($f->{_err}) {
$s->htmlHeader(title => 'Error creating message', page => $p, sec => 'issue');
p 'There was an error in the form. Please use the \'back\' button of your
browser to go back to the form (hopefully) without losing your message.
There was an error in the following fields: '.join(', ', map {(my$f=$_->[0])=~s/issue_// ;"\u$f"} @{$f->{_err}}).'.';
return $s->htmlFooter;
}
$s->resRedirect("/$p/issue/$l->{issue}", 'post');
}
sub issue_item {
my($s, $p, $i) = @_;
my $is = _issue_init($s, $p);
my $item = $is->dbItem($i);
return $s->resNotFound if !@$item;
my $last = $item->[$#$item];
$s->htmlHeader(title => 'Ncdc: '.$last->{summary}, page => $p, sec => 'issue');
br; a href => "/$p/issue", 'Back to the issue index'; br; br;
$is->htmlItem($item);
$is->htmlForm($last, "/$p/issue/post") if !$last->{closed};
br; a href => "/$p/issue", 'Back to the issue index'; br; br;
$s->htmlFooter;
}
package TUWF::Object; package TUWF::Object;
use TUWF ':html'; use TUWF ':html';
@ -266,9 +342,7 @@ sub htmlHeader {
my %o = (page => '', sec => '', sec2 => '', @_ ); my %o = (page => '', sec => '', sec2 => '', @_ );
html; html;
head; head;
style type => 'text/css'; Link rel => 'stylesheet', href => '/style.css', type => 'text/css', media => 'all';
$s->printCSS;
end;
Link rel => 'alternate', type => 'application/atom+xml', href => '/feed.atom', title => 'Site updates'; Link rel => 'alternate', type => 'application/atom+xml', href => '/feed.atom', title => 'Site updates';
title $o{title}; title $o{title};
end; end;
@ -337,6 +411,7 @@ sub htmlMenu {
$m->('/ncdc/man', 'Manual', $o{sec} eq 'man'); $m->('/ncdc/man', 'Manual', $o{sec} eq 'man');
$m->('/ncdc/changes', 'Changelog', $o{sec} eq 'changes'); $m->('/ncdc/changes', 'Changelog', $o{sec} eq 'changes');
$m->('/ncdc/scr', 'Screenshots', $o{sec} eq 'scr'); $m->('/ncdc/scr', 'Screenshots', $o{sec} eq 'scr');
$m->('/ncdc/issue', 'Issue tracker', $o{sec} eq 'issue');
}); });
$m->('/tuwf', 'Tuwf', $o{page} eq 'tuwf', sub { $m->('/tuwf', 'Tuwf', $o{page} eq 'tuwf', sub {
$m->('/tuwf', 'Info', !$o{sec}); $m->('/tuwf', 'Info', !$o{sec});
@ -361,69 +436,3 @@ sub htmlMenu {
end; end;
} }
sub printCSS {
# font-face code from http://fonts.googleapis.com/css?family=Buenard:700,400
lit <<' E;';
@font-face {
font-family: 'Buenard';
font-style: normal;
font-weight: bold;
src: local('Buenard Bold'), local('Buenard-Bold'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/8T0adwz_RAtKrxbccQmEFC3USBnSvpkopQaUR-2r7iU.ttf') format('truetype');
}
@font-face {
font-family: 'Buenard';
font-style: normal;
font-weight: 400;
src: local('Buenard'), local('Buenard-Regular'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/UUYHasP8umGDjV-yeZf27Q.ttf') format('truetype');
}
html,body { background: #ccc; text-align: center; height: 100% }
* { margin: 0; padding: 0; font: 15px 'Buenard',serif; color: #222 }
#body { text-align: left; width: 800px; margin: 0 auto; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; min-height: 100% }
#uglyhack { height: 30px }
#main, #left { float: left; border-top: 0px dashed #aaa, margin-top: 50px }
#left { width: 130px; border-right: 1px dashed #aaa; padding: 20px 10px; margin-bottom: 30px }
#main { width: 609px; padding: 12px 20px 30px 20px }
#footer { clear: left; width: 150px; margin: 0 0 0 324px; border-top: 1px dashed #aaa; height: 20px; text-align: center }
#footer p { position: relative; top: -10px; padding: 0; background: #fff; display: inline; color: #aaa }
#left h1 { font-weight: bold; text-align: center; font-size: 15px }
#left li { margin: 20px 0 0 10px; list-style-type: none }
#left li a { text-decoration: none; display: block; width: 120px; border-bottom: 1px solid #fff }
#left li a:hover { border-bottom: 1px dashed #aaa }
#left li li { margin-top: 10px }
#left li li a { width: 110px }
#left li li li { margin-top: 2px }
#left li li li a { width: 100px }
#left .menusel { color: #03a }
#left .notes { margin-top: 50px; text-align: center }
#left .notes, #left .notes a { font-size: 12px; text-decoration: none }
#left .notes a:hover { text-decoration: underline }
img.right { float: right; margin: 0 0 5px 10px }
.indexgroup { margin: 30px 10px 0px 20px }
.indexgroup li { list-style-type: none; margin-left: 0px }
.indexgroup li li { margin-left: 20px }
.indexgroup + .dummyTopAnchor + p { margin-top: 20px }
a.external:after { content: url(/img/external.gif) }
b { font-weight: bold }
h1.title { margin-top: 0; font-size: 25px }
h1 { margin-top: 50px; }
h2 { margin-top: 25px; }
h3 { margin-top: 0; margin-left: 10px }
h1, h1 a { font-size: 19px; color: #000; margin-bottom: 5px; text-decoration: none }
h2, h2 a { font-size: 16px; color: #000; margin-bottom: 1px; text-decoration: none }
h3, h3 a { font-size: 15px; color: #000; margin-bottom: 1px; text-decoration: none }
li { margin-left: 35px; margin-right: 15px; text-align: justify }
p { margin: 3px 15px 13px 15px; text-align: justify }
p + ul, p + ol { margin-top: -10px }
pre { padding-left: 0 }
pre, code, pre b { font: 11px monospace; }
pre b { font-weight: bold }
pre { background: #f5f5f5; border: 1px dotted #aaa; margin: 5px 10px; display: block; padding: 5px 5px 5px 0; }
dd { margin-left: 15px }
dt a { color: #333 }
dt { margin-left: 10px }
i { font-style: normal } /* TODO */
.sig { vertical-align: super }
.sig, .sig a { font-size: 12px; color: #333; text-decoration: none }
E;
}

87
style.css Normal file
View file

@ -0,0 +1,87 @@
/* font-face code from http://fonts.googleapis.com/css?family=Buenard:700,400 */
@font-face {
font-family: 'Buenard';
font-style: normal;
font-weight: bold;
src: local('Buenard Bold'), local('Buenard-Bold'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/8T0adwz_RAtKrxbccQmEFC3USBnSvpkopQaUR-2r7iU.ttf') format('truetype');
}
@font-face {
font-family: 'Buenard';
font-style: normal;
font-weight: 400;
src: local('Buenard'), local('Buenard-Regular'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/UUYHasP8umGDjV-yeZf27Q.ttf') format('truetype');
}
html,body { background: #ccc; text-align: center; height: 100% }
* { margin: 0; padding: 0; font: 15px 'Buenard',serif; color: #222 }
#body { text-align: left; width: 800px; margin: 0 auto; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; min-height: 100% }
#uglyhack { height: 30px }
#main, #left { float: left; border-top: 0px dashed #aaa, margin-top: 50px }
#left { width: 130px; border-right: 1px dashed #aaa; padding: 20px 10px; margin-bottom: 30px }
#main { width: 609px; padding: 12px 20px 30px 20px }
#footer { clear: left; width: 150px; margin: 0 0 0 324px; border-top: 1px dashed #aaa; height: 20px; text-align: center }
#footer p { position: relative; top: -10px; padding: 0; background: #fff; display: inline; color: #aaa }
#left h1 { font-weight: bold; text-align: center; font-size: 15px }
#left li { margin: 20px 0 0 10px; list-style-type: none }
#left li a { text-decoration: none; display: block; width: 120px; border-bottom: 1px solid #fff }
#left li a:hover { border-bottom: 1px dashed #aaa }
#left li li { margin-top: 10px }
#left li li a { width: 110px }
#left li li li { margin-top: 2px }
#left li li li a { width: 100px }
#left .menusel { color: #03a }
#left .notes { margin-top: 50px; text-align: center }
#left .notes, #left .notes a { font-size: 12px; text-decoration: none }
#left .notes a:hover { text-decoration: underline }
img.right { float: right; margin: 0 0 5px 10px }
.indexgroup { margin: 30px 10px 0px 20px }
.indexgroup li { list-style-type: none; margin-left: 0px }
.indexgroup li li { margin-left: 20px }
.indexgroup + .dummyTopAnchor + p { margin-top: 20px }
a.external:after { content: url(/img/external.gif) }
b { font-weight: bold }
h1.title { margin-top: 0; font-size: 25px }
h1 { margin-top: 50px; }
h2 { margin-top: 25px; }
h3 { margin-top: 0; margin-left: 10px }
h1, h1 a { font-size: 19px; color: #000; margin-bottom: 5px; text-decoration: none }
h2, h2 a { font-size: 16px; color: #000; margin-bottom: 1px; text-decoration: none }
h3, h3 a { font-size: 15px; color: #000; margin-bottom: 1px; text-decoration: none }
li { margin-left: 35px; margin-right: 15px; text-align: justify }
p { margin: 3px 15px 13px 15px; text-align: justify }
p + ul, p + ol { margin-top: -10px }
pre { padding-left: 0 }
pre, code, pre b { font: 11px monospace; }
pre b { font-weight: bold }
pre { background: #f5f5f5; border: 1px dotted #aaa; margin: 5px 10px; display: block; padding: 5px 5px 5px 0; }
dd { margin-left: 15px }
dt a { color: #333 }
dt { margin-left: 10px }
i { font-style: normal } /* TODO */
.sig { vertical-align: super }
.sig, .sig a { font-size: 12px; color: #333; text-decoration: none }
textarea, input, select { background: #fcfcfc; color: #000; border: 1px solid #999 }
textarea:focus, input:focus { background: #fff }
table { border-collapse: collapse }
table td { padding: 0 2px }
table thead td { font-weight: bold }
.issue_listing tbody tr:nth-child(odd) { background-color: #f4f4f4 }
.issue_listing { width: 95% }
.issue_col_id, .issue_col_type, .issue_col_status, .issue_col_date { white-space: nowrap }
.issue_closed td { text-decoration: line-through }
.issue_status { display: block; height: 20px }
.issue_status dt { float: left; font-weight: bold }
.issue_status dd { float: left; }
.issue_item h1 { margin-top: 30px }
.issue_item dt { clear: left; float: left; font-weight: bold; width: 60px }
.issue_item dd { float: left; padding-right: 20px }
.issue_item p { clear: left; padding-top: 5px }
.issue_frm fieldset { border: 0; margin-top: 40px }
.issue_frm legend { font-size: 19px; color: #000; }
.issue_frm li { list-style-type: none; margin-left: 10px; clear: left; padding-top: 5px }
.issue_frm label { display: block; width: 80px; float: left }
.issue_frm input, .issue_frm select { float: left }
.issue_frm textarea { width: 100%; height: 200px }
.issue_frm_submit input { float: right; width: 200px }
.issue_status dt::after, .issue_item dt::after, .issue_frm label::after { content: ":" }