diff --git a/Issue.pm b/Issue.pm
new file mode 100644
index 0000000..3242fa6
--- /dev/null
+++ b/Issue.pm
@@ -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 ' ';
+ 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 pastebin 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;
diff --git a/index.cgi b/index.cgi
index e891a55..88a148e 100755
--- a/index.cgi
+++ b/index.cgi
@@ -12,6 +12,7 @@ BEGIN { ($ROOT = abs_path $0) =~ s{index\.cgi$}{}; }
my @changes = (
+ [ '2012-03-17', '/ncdc/issue', 'Wrote a small issue tracker for ncdc' ],
[ '2012-03-14', '/ncdc', 'ncdc 1.9 released.' ],
[ '2012-02-15', '/doc/commvis', 'Added an article on my new communication system.' ],
[ '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/nccolour} => sub { podpage(shift, 'dump-nccolour', 'dump', 'nccolour', 'Colours in NCurses') },
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(
logfile => '/www/err.log',
error_404_handler => \¬found,
+ mail_from => 'Yorhel\'s issue tracker ',
# 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; },
);
@@ -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;
use TUWF ':html';
@@ -266,9 +342,7 @@ sub htmlHeader {
my %o = (page => '', sec => '', sec2 => '', @_ );
html;
head;
- style type => 'text/css';
- $s->printCSS;
- end;
+ Link rel => 'stylesheet', href => '/style.css', type => 'text/css', media => 'all';
Link rel => 'alternate', type => 'application/atom+xml', href => '/feed.atom', title => 'Site updates';
title $o{title};
end;
@@ -337,6 +411,7 @@ sub htmlMenu {
$m->('/ncdc/man', 'Manual', $o{sec} eq 'man');
$m->('/ncdc/changes', 'Changelog', $o{sec} eq 'changes');
$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', 'Info', !$o{sec});
@@ -361,69 +436,3 @@ sub htmlMenu {
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;
-}
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..0d34403
--- /dev/null
+++ b/style.css
@@ -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: ":" }