fu: Close-up of Fu, bringing a scoop of water to her mouth (Default)
fu ([personal profile] fu) wrote in [site community profile] changelog2010-10-28 04:26 am

[dw-free] Implement v-gifts

[commit: http://hg.dwscoalition.org/dw-free/rev/c4063e1602c6]

http://bugs.dwscoalition.org/show_bug.cgi?id=215

Backend modules and admin pages.

Patch by [personal profile] kareila.

Files modified:
  • bin/upgrading/base-data.sql
  • bin/upgrading/en.dat
  • bin/upgrading/update-db-general.pl
  • cgi-bin/Apache/LiveJournal.pm
  • cgi-bin/DW/Controller/Admin.pm
  • cgi-bin/DW/VirtualGift.pm
  • cgi-bin/LJ/Event/VgiftApproved.pm
  • cgi-bin/ljdefaults.pl
  • cgi-bin/ljlib.pl
  • htdocs/admin/vgifts/inactive.bml
  • htdocs/admin/vgifts/inactive.bml.text
  • htdocs/admin/vgifts/index.bml
  • htdocs/admin/vgifts/index.bml.text
  • htdocs/admin/vgifts/tags.bml
  • htdocs/admin/vgifts/tags.bml.text
  • views/admin/index.tt.text
--------------------------------------------------------------------------------
diff -r 4c7678ebddbf -r c4063e1602c6 bin/upgrading/base-data.sql
--- a/bin/upgrading/base-data.sql	Thu Oct 28 11:15:07 2010 +0800
+++ b/bin/upgrading/base-data.sql	Thu Oct 28 12:23:03 2010 +0800
@@ -566,6 +566,8 @@ UPDATE priv_list SET des='Allows a user 
 UPDATE priv_list SET des='Allows a user to modify bans with the sysban mechanism.  arg=A specific ban type the user can modify, or \"*\" for all ban type.',is_public='0',privname='Modify System Bans',scope='general' WHERE privcode='sysban';
 INSERT IGNORE INTO priv_list (des, is_public, privcode, privname, scope) VALUES ('Allows a user to edit site text in a given language. arg=Unique language code, optionally appended by |domainid.domaincode', '1', 'translate', 'Translate/Update Text', 'general');
 UPDATE priv_list SET des='Allows a user to edit site text in a given language. arg=Unique language code, optionally appended by |domainid.domaincode',is_public='1',privname='Translate/Update Text',scope='general' WHERE privcode='translate';
+INSERT IGNORE INTO priv_list (des, is_public, privcode, privname, scope) VALUES ('Allows a user to add/edit vgifts. arg=Tag in case restricting priv to a particular category is needed, or "*" for all tags.', '1', 'vgifts', 'Virtual Gifts', 'general');
+UPDATE priv_list SET des='Allows a user to add/edit vgifts. arg=Tag in case restricting priv to a particular category is needed, or "*" for all tags.',is_public='1',privname='Virtual Gifts',scope='general' WHERE privcode='vgifts';
 INSERT IGNORE INTO ratelist (des, name) VALUES ('Logged when a user adds someone to their Friends list', 'addfriend');
 UPDATE ratelist SET des='Logged when a user adds someone to their Friends list' WHERE name='addfriend';
 INSERT IGNORE INTO ratelist (des, name) VALUES ('Logged when a user creates a community.', 'commcreate');
diff -r 4c7678ebddbf -r c4063e1602c6 bin/upgrading/en.dat
--- a/bin/upgrading/en.dat	Thu Oct 28 11:15:07 2010 +0800
+++ b/bin/upgrading/en.dat	Thu Oct 28 12:23:03 2010 +0800
@@ -1983,6 +1983,20 @@ event.xpost.success.content=Crosspost to
 
 event.xpost.success.title=Crosspost of <a href="[[entryurl]]">[[entrydesc]]</a> to [[accountname]] action successful.
 
+event.vgift.approved.actions=<a [[aopts]]>View Gift</a>
+
+event.vgift.approved.content.N=[[admin]] has rejected your virtual gift submission "[[vgift]]".
+
+event.vgift.approved.content.Y=[[admin]] has approved your virtual gift submission "[[vgift]]".
+
+event.vgift.approved.msg.N=Sorry, your proposed virtual gift named "[[vgift]]" was not approved.
+
+event.vgift.approved.msg.Y=Congratulations, your proposed virtual gift named "[[vgift]]" was approved.
+
+event.vgift.approved.reason=The reviewer, [[admin]], included the following comments:
+
+event.vgift.notfound=The specified virtual gift could not be found.
+
 fckland.ljimage=Insert/Edit Image
 
 fcklang.cutcontents=Type your cut contents here.
@@ -3986,6 +4000,54 @@ userpic.link=userpic
 
 userpic.text=[[user]], this is what you currently look like to your friends: [[empty]]<br />Boooring. Be classy and upload a [[link]].
 
+vgift.display.cost.free=Free
+
+vgift.display.cost.points=[[cost]] [[?cost|point|points]]
+
+vgift.display.createdby=Created by [[user]] [[ago]]
+
+vgift.display.creatorlist.counts=[[user]] ([[approved]] approved, [[active]] active)
+
+vgift.display.label.cost=Cost:
+
+vgift.display.label.featured=Featured:
+
+vgift.display.label.tags=Tags:
+
+vgift.display.linktext.delete=[Delete]
+
+vgift.display.linktext.review=[Review]
+
+vgift.display.linktext.viewedit=[View/Edit]
+
+vgift.display.linktext.viewgifts=[View Gifts]
+
+vgift.error.create.noname=Name must be specified
+
+vgift.error.create.samename=Name already in use
+
+vgift.error.init.alloc=Could not allocate new vgiftid
+
+vgift.error.init.reuse=Cannot reuse vgift objects
+
+vgift.error.loadpic=<b>No [[size]] image found.</b>
+
+vgift.error.savepics=Unable to save [[size]] image
+
+vgift.error.tags.create=Cannot create new tag: [[tag]]
+
+vgift.error.tags.invalid=Attempt to use invalid tags: [[taglist]]
+
+vgift.error.tags.novalidtags=No valid tags!
+
+vgift.error.validate.value=Invalid value for [[prop]]
+
+vgift.error.validate.property=Invalid property '[[key]]'
+
+vgift.error.validate.text=Invalid text encoding for [[prop]]
+
+vgift.error.validate.value=Invalid value for [[prop]]
+
 web.ads.advertisement=<a [[aopts]]>Advertisement</a>
 
 web.ads.advertisement_nolink=Advertisement
diff -r 4c7678ebddbf -r c4063e1602c6 bin/upgrading/update-db-general.pl
--- a/bin/upgrading/update-db-general.pl	Thu Oct 28 11:15:07 2010 +0800
+++ b/bin/upgrading/update-db-general.pl	Thu Oct 28 12:23:03 2010 +0800
@@ -17,6 +17,49 @@ use strict;
 use strict;
 
 mark_clustered(@LJ::USER_TABLES);
+
+register_tablecreate("vgift_ids", <<'EOC');
+CREATE TABLE vgift_ids (
+    vgiftid      INT UNSIGNED NOT NULL PRIMARY KEY,
+    name         VARCHAR(255) NOT NULL,
+    created_t    INT UNSIGNED NOT NULL,   #unixtime
+    creatorid    INT UNSIGNED NOT NULL DEFAULT 0,
+    active       ENUM('Y','N') NOT NULL DEFAULT 'N',
+    featured     ENUM('Y','N') NOT NULL DEFAULT 'N',
+    custom       ENUM('Y','N') NOT NULL DEFAULT 'N',
+    approved     ENUM('Y','N'),
+    approved_by  INT UNSIGNED,
+    approved_why MEDIUMTEXT,
+    description  MEDIUMTEXT,
+    cost         INT UNSIGNED NOT NULL DEFAULT 0,
+    mime_small   VARCHAR(255),
+    mime_large   VARCHAR(255),
+
+    UNIQUE KEY (name)
+)
+EOC
+
+register_tablecreate("vgift_tags", <<'EOC');
+CREATE TABLE vgift_tags (
+    tagid      INT UNSIGNED NOT NULL,
+    vgiftid    INT UNSIGNED NOT NULL,
+
+    PRIMARY KEY (tagid, vgiftid),
+    INDEX (vgiftid),
+    INDEX (tagid)
+)
+EOC
+
+register_tablecreate("vgift_tagpriv", <<'EOC');
+CREATE TABLE vgift_tagpriv (
+    tagid      INT UNSIGNED NOT NULL,
+    prlid      SMALLINT UNSIGNED NOT NULL,
+    arg        VARCHAR(40),
+
+    PRIMARY KEY (tagid, prlid, arg),
+    INDEX (tagid)
+)
+EOC
 
 register_tablecreate("authactions", <<'EOC');
 CREATE TABLE authactions (
diff -r 4c7678ebddbf -r c4063e1602c6 cgi-bin/Apache/LiveJournal.pm
--- a/cgi-bin/Apache/LiveJournal.pm	Thu Oct 28 11:15:07 2010 +0800
+++ b/cgi-bin/Apache/LiveJournal.pm	Thu Oct 28 12:23:03 2010 +0800
@@ -39,6 +39,7 @@ use LJ::URI;
 use LJ::URI;
 use DW::Routing;
 use DW::Template;
+use DW::VirtualGift;
 
 BEGIN {
     $LJ::OPTMOD_ZLIB = eval "use Compress::Zlib (); 1;";
@@ -920,6 +921,8 @@ sub trans
     # userpic
     return userpic_trans($r) if $uri =~ m!^/userpic/!;
 
+    return vgift_trans($r) if $uri =~ m!^/vgift/!;
+
     # front page journal
     if ($LJ::FRONTPAGE_JOURNAL) {
         my $view = $determine_view->($LJ::FRONTPAGE_JOURNAL, "front", $uri);
@@ -1127,8 +1130,10 @@ sub userpic_content
     my $size;
 
     my $send_headers = sub {
-        $r->content_type($mime);
-        $r->headers_out->{"Content-length"} = $size+0;
+        $size = $_[0] if @_;
+        $size ||= 0;
+        $r->content_type( $mime );
+        $r->headers_out->{"Content-length"} = $size + 0;
         $r->headers_out->{"Cache-Control"} = "no-transform";
         $r->headers_out->{"Last-Modified"} = LJ::time_to_http($lastmod);
     };
@@ -1146,47 +1151,8 @@ sub userpic_content
     # For dversion 7+ and mogilefs userpics, follow this path
     if ( $pic->in_mogile ) {
         my $key = $u->mogfs_userpic_key( $picid );
-
-        if ( !$LJ::REPROXY_DISABLE{userpics} &&
-             $r->headers_in->{'X-Proxy-Capabilities'} &&
-             $r->headers_in->{'X-Proxy-Capabilities'} =~ m{\breproxy-file\b}i )
-        {
-            my $memkey = [$picid, "mogp.up.$picid"];
-
-            my $zone = $r->headers_in->{'X-MogileFS-Explicit-Zone'} || undef;
-            $memkey->[1] .= ".$zone" if $zone;
-
-            my $cache_for = $LJ::MOGILE_PATH_CACHE_TIMEOUT || 3600;
-
-            my $paths = LJ::MemCache::get($memkey);
-            unless ($paths) {
-                my @paths = LJ::mogclient()->get_paths( $key, { noverify => 1, zone => $zone });
-                $paths = \@paths;
-                LJ::MemCache::add($memkey, $paths, $cache_for) if @paths;
-            }
-
-            # reproxy url
-            if ($paths->[0] =~ m/^http:/) {
-                $r->headers_out->{'X-REPROXY-CACHE-FOR'} = "$cache_for; Last-Modified Content-Type";
-                $r->headers_out->{'X-REPROXY-URL'} = join(' ', @$paths);
-            }
-
-            # reproxy file
-            else {
-                $r->headers_out->{'X-REPROXY-FILE'} = $paths->[0];
-            }
-
-            $send_headers->();
-        }
-
-        else {
-            my $data = LJ::mogclient()->get_file_data( $key );
-            return NOT_FOUND unless $data;
-            $size = length $$data;
-            $send_headers->();
-            $r->print( $$data ) unless $r->header_only;
-        }
-
+        my $memkey = [$picid, "mogp.up.$picid"];
+        mogile_fetch( $r, $key, $memkey, 'userpics', $send_headers );
         return OK;
     }
 
@@ -1262,6 +1228,54 @@ sub files_trans
         return OK;
     }
     return 404;
+}
+
+sub vgift_trans
+{
+    my $r = shift;
+    return 404 unless $r->uri =~ m!^/vgift/(\d+)/(\w+)$!;
+    my ( $picid, $picsize ) = ( $1, $2 );
+    return 404 unless $picsize =~ /^(?:small|large)$/;
+
+    $r->notes->{codepath} = "img.vgift";
+
+    # we can safely do this without checking
+    # unless we're using the admin interface
+    return HTTP_NOT_MODIFIED if $r->headers_in->{'If-Modified-Since'}
+        && $r->headers_in->{'Referer'} !~ m!^\Q$LJ::SITEROOT\E$/admin/!;
+
+    $RQ{picid} = $picid;
+    $RQ{picsize} = $picsize;
+
+    $r->handler( "perl-script" );
+    $r->push_handlers( PerlResponseHandler => \&vgift_content );
+    return OK;
+}
+
+sub vgift_content
+{
+    my $r = shift;
+    my $picid = $RQ{picid};
+    my $picsize = $RQ{picsize};
+
+    my $vg = DW::VirtualGift->new( $picid );
+    my $mime = $vg->mime_type( $picsize );
+    return NOT_FOUND unless $mime;
+
+    my $size;
+
+    my $send_headers = sub {
+        $size = $_[0] if @_;
+        $size ||= 0;
+        $r->content_type( $mime );
+        $r->headers_out->{"Content-length"} = $size + 0;
+        $r->headers_out->{"Cache-Control"} = "no-transform";
+    };
+
+    my $key = $vg->img_mogkey( $picsize );
+    my $memkey = $vg->img_memkey( $picsize ); #[$picid, "mogp.vg.$picsize.$picid"];
+    mogile_fetch( $r, $key, $memkey, 'vgifts', $send_headers );
+    return OK;
 }
 
 sub journal_content
@@ -1781,6 +1795,45 @@ sub anti_squatter
 
 }
 
+sub mogile_fetch {
+    my ( $r, $key, $memkey, $class, $send_headers ) = @_;
+
+    if ( !$LJ::REPROXY_DISABLE{$class} &&
+         $r->headers_in->{'X-Proxy-Capabilities'} &&
+         $r->headers_in->{'X-Proxy-Capabilities'} =~ m{\breproxy-file\b}i ) {
+
+        my $zone = $r->headers_in->{'X-MogileFS-Explicit-Zone'} || undef;
+        $memkey->[1] .= ".$zone" if $zone;
+
+        my $cache_for = $LJ::MOGILE_PATH_CACHE_TIMEOUT || 3600;
+
+        my $paths = LJ::MemCache::get( $memkey );
+        unless ( $paths ) {
+            # load and add to memcache
+            my @paths = LJ::mogclient()->get_paths( $key, { noverify => 1, zone => $zone } );
+            $paths = \@paths;
+            LJ::MemCache::add( $memkey, $paths, $cache_for ) if @paths;
+        }
+
+        if ( defined $paths->[0] && $paths->[0] =~ m/^http:/ ) {
+            # reproxy url
+            $r->headers_out->{'X-REPROXY-CACHE-FOR'} = "$cache_for; Last-Modified Content-Type";
+            $r->headers_out->{'X-REPROXY-URL'} = join( ' ', @$paths );
+        } else {
+            # reproxy file
+            $r->headers_out->{'X-REPROXY-FILE'} = $paths->[0];
+        }
+
+        $send_headers->();
+
+    } else {  # no reproxy
+        my $data = LJ::mogclient()->get_file_data( $key );
+        return NOT_FOUND unless $data;
+        $send_headers->( length $$data );
+        $r->print( $$data ) unless $r->header_only;
+    }
+}
+
 package LJ::Protocol;
 use Encode();
 
diff -r 4c7678ebddbf -r c4063e1602c6 cgi-bin/DW/Controller/Admin.pm
--- a/cgi-bin/DW/Controller/Admin.pm	Thu Oct 28 11:15:07 2010 +0800
+++ b/cgi-bin/DW/Controller/Admin.pm	Thu Oct 28 12:23:03 2010 +0800
@@ -109,6 +109,10 @@ DW::Controller::Admin->_register_admin_p
         '.admin.translate.link', '.admin.translate.text' ],
     [ 'userlog',
         '.admin.userlog.link', '.admin.userlog.text', [ 'canview:userlog', 'canview:*' ] ],
+    [ 'vgifts/',
+        '.admin.vgifts.link', '.admin.vgifts.text', [ 'siteadmin:vgifts', 'vgifts', sub {
+            return ( $LJ::IS_DEV_SERVER, LJ::Lang::ml( "/admin/index.tt.devserver" ) );
+        } ] ],
 );
 
 
diff -r 4c7678ebddbf -r c4063e1602c6 cgi-bin/DW/VirtualGift.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/DW/VirtualGift.pm	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,1165 @@
+#!/usr/bin/perl
+#
+# DW::VirtualGift - Provide virtual gifts for users
+#
+# Authors:
+#      Jen Griffin <kareila@livejournal.com>
+#
+# Copyright (c) 2010 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself. For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+
+package DW::VirtualGift;
+
+use strict;
+use warnings;
+
+use constant PROPLIST => qw/ vgiftid name created_t creatorid active
+                             approved approved_by approved_why
+                             custom featured description cost
+                             mime_small mime_large /;
+# NOTE: remember to update &validate if you add new props
+
+use base 'LJ::MemCacheable';
+# MemCacheable methods ###################################
+   *_memcache_id                = \&id;                  #
+   *_memcache_hashref_to_object = \&absorb_row;          #
+sub _memcache_key_prefix   { 'vgift_obj' }               #
+sub _memcache_expires      { 24*3600 }                   #
+sub _memcache_stored_props { return ( '1', PROPLIST ) }  #
+# end MemCacheable methods ###############################
+
+use Digest::MD5 qw/ md5_hex /;
+use LJ::Event::VgiftApproved;
+
+
+# TABLE OF CONTENTS
+#
+# 1. Constructor methods (anything that modifies db values)
+# 2. Accessor methods (simple db reads and booleans)
+# 3. Memcache methods (for referencing & expiring keys)
+# 4. Validation methods (for checking user-supplied data)
+# 5. Aggregate methods (for mass lookups)
+# 6. End-user display methods (making things look purty)
+# 7. Notification methods (let people know about things)
+
+
+
+# 1. Constructor methods
+sub new {
+    my ( $class, $id ) = @_;
+    return undef if !$id || $id !~ /^\d+$/;
+    my $self = { vgiftid => $id };
+    bless $self, ( ref $class ? ref $class : $class );
+    return $self;
+}
+
+sub _init {
+    # This should only be called by the "create" method.
+    # It grabs an ID for the new vgift before calling the "new" method.
+    my ( $class, $err ) = @_;
+    if ( ref $class ) {
+        $$err = LJ::Lang::ml('vgift.error.init.reuse') if $err;
+        return undef;
+    }
+
+    my $vgiftid = LJ::alloc_global_counter('V');
+    unless ( $vgiftid ) {
+        $$err = LJ::Lang::ml('vgift.error.init.alloc') if $err;
+        return undef;
+    }
+
+    # we have an id, now initialize the object
+    return $class->new( $vgiftid );
+}
+
+sub create {
+    # opts are values for object properties as defined in PROPLIST.
+    # also allowed: 'error' which should be a scalar reference;
+    # 'img_small' & 'img_large' which should contain raw
+    # image data to be stored in MogileFS.
+    my ( $class, %opts ) = @_;
+    my %vg;  # hash for storing row data
+    foreach ( PROPLIST ) {
+        $vg{$_} = $opts{$_} if defined $opts{$_};
+        # translate Perl nulls into MySQL nulls
+        $vg{$_} = undef if exists $vg{$_} && $vg{$_} eq '';
+    }
+    # don't allow created_t to be overridden
+    $vg{created_t} = time;
+
+    # enforce active/approved defaults for new gifts
+    if ( $vg{custom} && $vg{custom} eq 'Y' ) {
+        $vg{active} = 'Y';
+        $vg{approved} = 'N';
+    } else {
+        delete @vg{qw( active approved )};
+    }
+
+    # name is required
+    unless ( $vg{name} ) {
+        ${$opts{error}} = LJ::Lang::ml('vgift.error.create.noname');
+        return undef;
+    }
+
+    # name must be unique
+    my $dbr = LJ::get_db_reader();
+    my $exists = $dbr->selectrow_array( "SELECT name FROM vgift_ids " .
+                                        "WHERE name=?", undef, $vg{name} );
+    die $dbr->errstr if $dbr->err;
+
+    if ( $exists ) {
+        ${$opts{error}} = LJ::Lang::ml('vgift.error.create.samename');
+        return undef;
+    }
+    undef $dbr;  # release handle
+
+    # creatorid defaults to the logged in user if there is one
+    $vg{creatorid} = LJ::get_remote() unless defined $vg{creatorid};
+    $vg{creatorid} = LJ::want_userid( $vg{creatorid} );
+
+    # validate input
+    return undef unless $class->validate_all( $opts{error}, \%vg );
+
+    # now that we're reasonably certain we have good data,
+    # grab an id and get to work
+    my $self = $class->_init( $opts{error} ) or return undef;
+    $vg{vgiftid} = $self->id;
+
+    # save pictures here, after getting id but before updating DB
+    return undef unless $self->_savepics( \%vg, %opts );
+
+    # construct SQL statement
+    my $dbh = LJ::get_db_writer();
+    my $props = join( ', ', keys %vg );
+    my $qs = join( ', ', map { '?' } keys %vg );
+    $dbh->do( "INSERT INTO vgift_ids ($props) VALUES ($qs)", undef, values %vg );
+    die $dbh->errstr if $dbh->err;
+
+    $self->_expire_aggregate_keys;
+    return $self->absorb_row( \%vg );
+}
+
+sub _savepic {
+    my ( $self, $size, $data ) = @_;
+    return undef unless $data && $self->id;
+
+    # img_mogkey checks $size, don't need to explicitly check here
+    return undef unless my $key = $self->img_mogkey( $size );
+
+    my %mime = ( JPG => 'image/jpeg',
+                 GIF => 'image/gif',
+                 PNG => 'image/png',
+               );
+    my ( undef, undef, $filetype ) = Image::Size::imgsize( $data );
+    return undef unless $mime{$filetype};
+
+    return undef unless my $mc = LJ::mogclient();
+    return undef unless $mc->store_content( $key, 'vgifts', $data );
+
+    return $mime{$filetype};
+}
+
+sub _savepics {
+    my ( $self, $ref, %opts ) = @_;
+    return undef unless $self->id;
+    return undef unless ref $ref eq 'HASH';
+
+    my $mime_small = $self->_savepic( 'small', $opts{img_small} );
+    if ( $opts{img_small} && ! $mime_small ) {
+        ${$opts{error}} = LJ::Lang::ml( 'vgift.error.savepics',
+                                   { size => 'small' } );
+        return undef;
+    }
+    my $mime_large = $self->_savepic( 'large', $opts{img_large} );
+    if ( $opts{img_large} && ! $mime_large ) {
+        ${$opts{error}} = LJ::Lang::ml( 'vgift.error.savepics',
+                                   { size => 'large' } );
+        return undef;
+    }
+    $ref->{mime_small} = $mime_small if $mime_small;
+    $ref->{mime_large} = $mime_large if $mime_large;
+
+    return 1;
+}
+
+sub edit {
+    # opts are values for object properties as defined in PROPLIST.
+    # also allowed: 'error' which should be a scalar reference;
+    # 'img_small' & 'img_large' which should contain raw
+    # image data to be stored in MogileFS.
+    my ( $self, %opts ) = @_;
+    return undef unless $self->id;
+
+    my %vg;  # hash for storing row data
+    foreach ( PROPLIST ) {
+        $vg{$_} = $opts{$_} if defined $opts{$_};
+        # translate Perl nulls into MySQL nulls
+        $vg{$_} = undef if exists $vg{$_} && $vg{$_} eq '';
+    }
+    # don't allow created_t or vgiftid to be overridden
+    delete @vg{qw( created_t vgiftid )};
+
+    $vg{creatorid} = LJ::want_userid( $vg{creatorid} )
+        if defined $vg{creatorid};
+
+    # save pictures first
+    return undef unless $self->_savepics( \%vg, %opts );
+
+    return $self unless %vg;  # no DB updates
+
+    # validate input
+    return undef unless $self->validate_all( $opts{error}, \%vg );
+
+    # expire aggregate keys based on current values
+    # (we expire again below, but that is based on the new values)
+    $self->_expire_aggregate_keys;
+
+    # construct SQL statement with new values
+    my $dbh = LJ::get_db_writer();
+    my @keys = keys %vg;
+    my $props = join( ', ', map { "$_=?" } @keys );
+    $dbh->do( "UPDATE vgift_ids SET $props WHERE vgiftid=?",
+              undef, values %vg, $self->id );
+    die $dbh->errstr if $dbh->err;
+
+    # update objects in memory
+    $self->{$_} = $vg{$_} foreach @keys;
+    $self->_remove_from_memcache;  # LJ::MemCacheable
+    $self->_expire_relevant_keys( @keys );
+
+    return $self;
+}
+
+sub mark_active   { $_[0]->edit( active => 'Y' ) }
+sub mark_inactive { $_[0]->edit( active => 'N' ) }
+
+sub tags {
+    # taglist is a comma separated string of tagnames.
+    # opts allowed: 'error' which should be a scalar reference;
+    # 'autovivify' boolean allowing new tags to be created in the DB.
+    # returns array of tagnames (or arrayref in scalar context)
+    my ( $self, $taglist, %opts ) = @_;
+    return undef unless my $id = $self->id;
+    return undef if $self->is_custom;  # can't tag custom gifts
+    my $autovivify = $opts{autovivify};
+
+    my $error = sub {
+        ${$opts{error}} = shift if $opts{error};
+        return undef;
+    };
+
+    my $tagnames;
+
+    if ( defined $taglist ) {  # save new tags
+        # taglist can be an arrayref or a comma separated string
+        my @newtags = ref $taglist eq 'ARRAY' ? @$taglist :
+                      LJ::interest_string_to_list( $taglist );
+        # vgift tags are similar enough to interests that we can reuse code
+        unless ( @newtags ) {  # just wipe existing tags and return
+            $self->_tagwipe;
+            return wantarray ? () : [];
+        }
+        my @valid_tags = LJ::validate_interest_list( @newtags );
+        my %invalid_tags = map { lc( $_ ) => 1 } @newtags;
+        delete @invalid_tags{@valid_tags};
+
+        if ( %invalid_tags ) {
+            return $error->( LJ::Lang::ml( 'vgift.error.tags.invalid', { taglist =>
+                    $self->display_taglist( [ keys %invalid_tags ] ) } ) );
+        }
+
+        # this shouldn't be possible, but just in case...
+        return $error->( LJ::Lang::ml('vgift.error.tags.novalidtags') )
+            unless @valid_tags;
+
+        $tagnames = \@valid_tags;  # save for later
+
+        # At this point we have the list of tag names to return,
+        # but we still need to store the tag ids in the database.
+
+        my $dbr = LJ::get_db_reader();
+        my $qs = join( ', ', map { '?' } @valid_tags );
+        my $dbdata = $dbr->selectall_arrayref( "SELECT keyword, kwid FROM sitekeywords " .
+                                               "WHERE keyword IN ($qs)", undef, @valid_tags );
+        die $dbr->errstr if $dbr->err;
+
+        my %dbtags = map { $_->[0] => $_->[1] } @$dbdata;
+        foreach my $tag ( @valid_tags ) {
+            next if $dbtags{$tag};
+            # try to create the tag if it didn't already exist
+            my $tagid = LJ::get_sitekeyword_id( $tag, $autovivify );
+            return $error->( LJ::Lang::ml( 'vgift.error.tags.create',
+                { tag => LJ::ehtml( $tag ) } ) ) unless $tagid;
+            $dbtags{$tag} = $tagid;
+        }
+
+        # delete previous tags & clear memcached tag data
+        $self->_tagwipe;
+
+        # construct SQL statement for adding new tags
+        my $dbh = LJ::get_db_writer();
+        my @tagids = values %dbtags;
+        my $qps = join( ', ', map { '(?,?)' } @tagids );
+        my @vals = map { ( $id, $_ ) } @tagids;
+        $dbh->do( "INSERT INTO vgift_tags (vgiftid, tagid) VALUES $qps", undef, @vals );
+        die $dbh->errstr if $dbh->err;
+
+    } else {  # fetch existing tags
+        my $tags = LJ::MemCache::get( $self->taglist_memkey );
+        if ( $tags && ref $tags eq "ARRAY" ) {
+            return wantarray ? @$tags : $tags;  # fast path out
+        }
+        my $dbr = LJ::get_db_reader();
+        $tagnames = $dbr->selectcol_arrayref(
+            "SELECT keyword FROM sitekeywords WHERE kwid IN " .
+            "(SELECT tagid FROM vgift_tags WHERE vgiftid=$id)"
+            );
+        die $dbr->errstr if $dbr->err;
+    }
+
+    LJ::MemCache::set( $self->taglist_memkey, $tagnames, 3600*24 );
+    return wantarray ? @$tagnames : $tagnames;
+}
+
+sub _tagwipe {
+    my ( $self, $tagid ) = @_;
+    return undef unless my $id = $self->id;
+
+    # Acts on a single gift.  Remove $tagid from this gift,
+    # or if no $tagid specified, remove ALL tags from this gift.
+
+    my $dbh = LJ::get_db_writer();
+    if ( $tagid ) {
+        $dbh->do( "DELETE FROM vgift_tags WHERE vgiftid=$id AND tagid=?",
+                  undef, $tagid );
+    } else {
+        $dbh->do( "DELETE FROM vgift_tags WHERE vgiftid=$id" );
+    }
+    die $dbh->errstr if $dbh->err;
+    $self->_expire_taglist_keys;
+    return 1;
+}
+
+sub remove_tag_by_id {
+    my ( $self, $tagid ) = @_;
+    return undef unless $tagid && $tagid =~ /^\d+$/;
+    return $self->_tagwipe( $tagid );
+}
+
+sub alter_tag {
+    my ( $self, $tagname, $newname ) = @_;
+
+    # For every gift that has $tagname, remove that tag.
+    # If $newname is provided, replace $tagname with $newname.
+
+    my $oldid = $self->get_tagid( $tagname );
+    return undef unless $oldid;
+
+    # We need to cache @vgs here before we do the SQL update,
+    # so that we remember which gifts we were acting on
+    # once the tags have been rewritten in the database.
+
+    my @vgs = $self->list_tagged_with( $tagname );
+    my $dbh = LJ::get_db_writer();
+
+    if ( $newname ) {
+        my $newid = $self->create_tag( $newname );
+        return undef unless $newid;
+
+        $dbh->do( "UPDATE vgift_tags SET tagid=$newid WHERE tagid=$oldid" );
+        die $dbh->errstr if $dbh->err;
+        $dbh->do( "UPDATE vgift_tagpriv SET tagid=$newid WHERE tagid=$oldid" );
+        die $dbh->errstr if $dbh->err;
+
+    } else {
+        $dbh->do( "DELETE FROM vgift_tags WHERE tagid=$oldid" );
+        die $dbh->errstr if $dbh->err;
+        $dbh->do( "DELETE FROM vgift_tagpriv WHERE tagid=$oldid" );
+        die $dbh->errstr if $dbh->err;
+    }
+
+    $self->_expire_taglist_keys( @vgs );
+    return 1;
+}
+
+sub create_tag {
+    my ( $self, $tagname ) = @_;
+    return LJ::get_sitekeyword_id( $tagname, 1 );
+}
+
+sub _addremove_tagpriv {
+    my ( $self, $sql, $tagname, $privname, $arg ) = @_;
+    return undef unless $sql && $tagname && $privname;
+    my $tagid = $self->get_tagid( $tagname ) or return undef;
+    my $prlid = $self->validate_priv( $privname ) or return undef;
+
+    my $dbh = LJ::get_db_writer();
+    $dbh->do( $sql, undef, $tagid, $prlid, $arg );
+    die $dbh->errstr if $dbh->err;
+    return 1;
+}
+
+sub add_priv_to_tag {
+    my $self = shift;
+    return $self->_addremove_tagpriv(
+        "INSERT IGNORE INTO vgift_tagpriv (tagid, prlid, arg)" .
+        " VALUES (?,?,?)", @_ );
+}
+
+sub remove_priv_from_tag {
+    my $self = shift;
+    return $self->_addremove_tagpriv(
+        "DELETE FROM vgift_tagpriv WHERE tagid=? AND prlid=?" .
+        " AND arg=?", @_ );
+}
+
+sub delete {
+    my ( $self, $u ) = @_;
+    return undef unless my $id = $self->id;
+    $u = $self->creator unless LJ::isu( $u );
+    return undef unless $self->can_be_deleted_by( $u );
+
+    # delete pictures from mogilefs
+    if ( $LJ::MOGILEFS_CONFIG{hosts} ) {
+        LJ::mogclient()->delete( $self->img_mogkey( 'large' ) );
+        LJ::mogclient()->delete( $self->img_mogkey( 'small' ) );
+    }
+
+    # wipe the relevant rows and memkeys
+    $self->_tagwipe;
+    $self->_expire_relevant_keys;
+    $self->_remove_from_memcache;  # LJ::MemCacheable
+
+    my $dbh = LJ::get_db_writer();
+    $dbh->do( "DELETE FROM vgift_ids WHERE vgiftid=$id" );
+    die $dbh->errstr if $dbh->err;
+    return 1;
+}
+
+
+# 2. Accessor methods
+sub id { return $_[0]->{vgiftid} }
+*vgiftid = \&id;
+
+sub name { return $_[0]->_access( 'name' ) || ''; }
+sub name_ehtml { return LJ::ehtml( $_[0]->name ); }
+sub description { return $_[0]->_access( 'description' ) || ''; }
+sub description_ehtml { return LJ::ehtml( $_[0]->description ); }
+
+sub cost { return $_[0]->_access( 'cost' )           ||  0  }
+sub active { return $_[0]->_access( 'active' )       || 'N' }
+sub custom { return $_[0]->_access( 'custom' )       || 'N' }
+sub featured { return $_[0]->_access( 'featured' )   || 'N' }
+sub creatorid { return $_[0]->_access( 'creatorid' ) ||  0  }
+sub created_t { return $_[0]->_access( 'created_t' ) }
+
+sub creator { return LJ::load_userid( $_[0]->creatorid ) }
+sub is_inactive { return $_[0]->active eq 'N' ? 1 : 0 }
+sub is_active { return $_[0]->active eq 'Y' ? 1 : 0 }
+sub is_custom { return $_[0]->custom eq 'Y' ? 1 : 0 }
+sub is_featured { return $_[0]->featured eq 'Y' ? 1 : 0 }
+sub is_free { return $_[0]->cost ? 0 : 1 }
+
+sub approved { return $_[0]->_access( 'approved' )         || '' }
+sub approved_by { return $_[0]->_access( 'approved_by' )   || '' }
+sub approved_why { return $_[0]->_access( 'approved_why' ) || '' }
+sub is_approved { return $_[0]->approved eq 'Y' ? 1 : 0 }
+sub is_rejected { return $_[0]->approved eq 'N' ? 1 : 0 }
+sub is_queued   { return $_[0]->approved ? 0 : 1 }
+sub approver { return LJ::load_userid( $_[0]->approved_by ) }
+
+sub img_small { return $_[0]->_loadpic( 'small' ) }
+sub img_large { return $_[0]->_loadpic( 'large' ) }
+sub img_small_html { return $_[0]->_loadpic_html( 'small' ) }
+sub img_large_html { return $_[0]->_loadpic_html( 'large' ) }
+sub mime_small { return $_[0]->_access( 'mime_small' ) }
+sub mime_large { return $_[0]->_access( 'mime_large' ) }
+sub mime_type {
+    my ( $self, $size ) = @_;
+    return undef unless $size;
+    return $self->mime_small if $size eq 'small';
+    return $self->mime_large if $size eq 'large';
+    return undef;  # invalid size
+}
+
+sub _access {
+    my ( $self, $prop ) = @_;
+    return undef unless $prop = lc $prop;
+    return $self->{$prop} if defined $self->{$prop};
+    $self->_load;
+    return $self->{$prop};
+}
+
+sub _load {
+    my $self = shift;
+    return undef unless $self->id;
+
+    return $self if $self->{_loaded};  # from absorb_row
+    return $self if $self->_load_from_memcache;  # LJ::MemCacheable
+
+    # find row in database
+    my $dbr = LJ::get_db_reader();
+    my $props = join( ', ', PROPLIST );
+
+    my $row = $dbr->selectrow_hashref(
+        "SELECT $props FROM vgift_ids WHERE vgiftid=?",
+        undef, $self->id );
+    die $dbr->errstr if $dbr->err;
+    return undef unless $row;
+
+    # store retrieved data
+    $self->absorb_row( $row );
+    $self->_store_to_memcache;  # LJ::MemCacheable
+
+    return $self;
+}
+
+sub absorb_row {
+    my ( $self, $row ) = @_;
+    return undef unless $row;
+
+    $self->{$_} = $row->{$_} foreach PROPLIST;
+    $self->{_loaded} = 1;
+    return $self;
+}
+
+sub _loadpic {
+    my ( $self, $size ) = @_;
+
+    return undef unless my $id = $self->id;
+    return undef unless $self->mime_type( $size );
+
+    return "$LJ::SITEROOT/vgift/$id/$size";
+}
+
+sub _loadpic_html {
+    my ( $self, $size ) = @_;
+
+    return '' unless $size && $size =~ /^(small|large)$/;
+    return '' unless $self->id;
+    my $url = $self->_loadpic( $size );
+    return LJ::Lang::ml( 'vgift.error.loadpic', { size => $size } )
+        unless $url;
+
+    my $name = $self->name_ehtml;
+    my $desc = $self->description_ehtml;
+    return "<img alt='$desc' title='$name' src='$url' />";
+}
+
+sub img_mogkey {
+    my ( $self, $size ) = @_;
+    return undef unless $size && $size =~ /^(small|large)$/;
+    return undef unless my $id = $self->id;
+    return "vgift_img_$size:$id";
+}
+
+# tagnames and interests are both in sitekeywords
+sub get_tagname { return LJ::get_interest( $_[1] ) }
+
+sub get_tagid { return LJ::get_sitekeyword_id( $_[1], 0 ) }
+
+sub can_be_approved_by {
+    my ( $self, $u ) = @_;
+    $u = LJ::want_user( $u ) or return undef;
+
+    # creators can't approve their own gifts
+    return 0 if $u->equals( $self->creator );
+
+    # otherwise, same privileges as for edits
+    return $self->can_be_edited_by( $u );
+}
+
+sub can_be_edited_by {
+    my ( $self, $u ) = @_;
+
+    # don't allow editing of gifts that are active in the shop
+    return 0 if $self->is_active;
+
+    $u = LJ::want_user( $u ) or return undef;
+    # creators can edit their own inactive gifts
+    return 1 if $u->equals( $self->creator );
+    # siteadmins can edit any inactive gift
+    return 1 if $u->has_priv( 'siteadmin', 'vgifts' );
+
+    return 0;
+}
+
+sub can_be_deleted_by {
+    my $self = shift;
+
+    # FIXME: if the vgift has been purchased, don't allow
+
+    # otherwise, same privileges as for edits
+    return $self->can_be_edited_by( @_ );
+}
+
+sub checksum {
+    my ( $self ) = @_;
+    return unless $self->_load;
+
+    # generate a checksum based on attribute values
+    my @attrvals;
+    foreach my $prop ( PROPLIST ) {
+        push @attrvals, $self->{$prop} || 'NULL';
+    }
+
+    if ( my $mc = LJ::mogclient() ) {
+        foreach my $size ( qw( large small ) ) {
+            my $data = $mc->get_file_data( $self->img_mogkey( $size ) );
+            push @attrvals, $data ? $$data : 'NULL';
+        }
+    }
+
+    return md5_hex( join ' ', @attrvals );
+}
+
+sub created_ago_text {
+    my ( $self ) = @_;
+    return '' unless $self->id;
+    return LJ::diff_ago_text( $self->created_t );
+}
+
+sub is_untagged {
+    my ( $self ) = @_;
+    my $id = $self->id or return undef;
+    foreach ( $self->list_untagged ) {
+        return 1 if $id == $_->id;
+    }
+    return 0;  # not in the untagged list
+}
+
+
+# 3. Memcache methods
+sub _expire_relevant_keys {
+    # this is called from delete/edit to expire specific keys
+    # relevant to the particular object being acted on.
+    my ( $self, @props ) = @_;
+    return undef unless $self->id;
+    @props = PROPLIST unless @props;
+    my %prop = map { $_ => 1 } @props;
+
+    if ( $prop{mime_small} ) {
+        # expire memcache for img_small (set in Apache::LiveJournal)
+        LJ::MemCache::delete( $self->img_memkey( 'small' ) );
+    }
+
+    if ( $prop{mime_large} ) {
+        # expire memcache for img_large (set in Apache::LiveJournal)
+        LJ::MemCache::delete( $self->img_memkey( 'large' ) );
+    }
+    
+    return $self->_expire_aggregate_keys( @props );
+}
+
+sub _expire_aggregate_keys {
+    # this is called from create/edit to expire aggregate keys
+    # based on specified values, or from _expire_relevant_keys.
+    my ( $self, @props ) = @_;
+    return undef unless $self->id;
+    @props = PROPLIST unless @props;
+    my %prop = map { $_ => 1 } @props;
+
+    if ( $prop{creatorid} ) {
+        # expire memcache for list_created_by
+        LJ::MemCache::delete( $self->created_by_memkey );
+    }
+    
+    if ( $prop{creatorid} || $prop{active} || $prop{approved} ) {
+        # expire memcache for fetch_creatorcounts
+        LJ::MemCache::delete( $self->creatorcounts_memkey );
+    }
+    
+    return $self;
+}
+
+sub _expire_taglist_keys {
+    # this is called from _tagwipe to expire tag-related keys.
+    my ( $self, @vgs ) = @_;  # may pass in additional vgift objects
+    @vgs = ( $self ) unless @vgs;
+    foreach ( @vgs ) {
+        next unless $_->id;
+        # expire memcache for vgift list of tags
+        LJ::MemCache::delete( $_->taglist_memkey );
+    }
+    # the rest are aggregate and only need to be expired once
+
+    # expire memcache for fetch_tagcounts methods
+    LJ::MemCache::delete( $self->tagcounts_approved_memkey );
+    LJ::MemCache::delete( $self->tagcounts_active_memkey );
+    # expire memcache for list_untagged
+    LJ::MemCache::delete( $self->untagged_memkey );
+    # expire memcache for list_nonpriv_tags
+    LJ::MemCache::delete( $self->nonpriv_tags_memkey );
+    # we can't force expiry of all individual user taglists
+    # or tagid lists - these are uncached every few minutes
+
+    return $self;
+}
+
+sub img_memkey {
+    my ( $self, $size ) = @_;
+    return undef unless $size && $size =~ /^(small|large)$/;
+    return undef unless my $id = $self->id;
+    return [$id, "mogp.vg.$size.$id"];
+}
+
+sub created_by_memkey {
+    my ( $self, $uid )  = @_;
+    $uid = $self->creatorid unless defined $uid;
+    return [$uid, "vgift.creatorid.$uid"];
+}
+
+sub taglist_memkey {
+    my ( $self )  = @_;
+    return undef unless my $id = $self->id;
+    return [$id, "vgift.taglist.$id"];  # list of tags for this giftid
+}
+
+sub tagged_with_memkey {
+    my ( $self, $tagid )  = @_;
+    return undef unless defined $tagid;
+    return [$tagid, "vgift.tagid.$tagid"];  # list of gifts for this tagid
+}
+
+sub untagged_memkey { return 'vgift_untagged'; }
+
+sub tagcounts_approved_memkey { return 'vgift_tagcounts_approved'; }
+
+sub tagcounts_active_memkey { return 'vgift_tagcounts_active'; }
+
+sub creatorcounts_memkey { return 'vgift_creatorcounts'; }
+
+sub nonpriv_tags_memkey { return 'vgift_nonpriv_tags'; }
+
+
+# 4. Validation methods
+sub validate_all {
+    my ( $self, $err, $arg ) = @_;
+    # err is optional scalar reference for error message.
+    # arg is optional hashref; if missing, validate the object.
+    my $data = $arg || $self;
+    my $ok = 1;
+    $ok &&= $self->validate( $_ => $data->{$_}, $err ) foreach PROPLIST;
+    return $ok;
+}
+
+sub validate {
+    my ( $self, $key, $val, $err ) = @_;
+
+    return $self->_valid_mime( $val, $err, $key ) if $key eq 'mime_small';
+    return $self->_valid_mime( $val, $err, $key ) if $key eq 'mime_large';
+    return $self->_valid_text( $val, $err, $key ) if $key eq 'description';
+    return $self->_valid_text( $val, $err, $key ) if $key eq 'approved_why';
+    return $self->_valid_name( $val, $err, $key ) if $key eq 'name';
+    return $self->_valid_y_n( $val, $err, $key )  if $key eq 'active';
+    return $self->_valid_y_n( $val, $err, $key )  if $key eq 'custom';
+    return $self->_valid_y_n( $val, $err, $key )  if $key eq 'featured';
+    return $self->_valid_y_n( $val, $err, $key )  if $key eq 'approved';
+    return $self->_valid_uid( $val, $err, $key )  if $key eq 'approved_by';
+    return $self->_valid_uid( $val, $err, $key )  if $key eq 'creatorid';
+    return $self->_valid_int( $val, $err, $key )  if $key eq 'vgiftid';
+    return $self->_valid_int( $val, $err, $key )  if $key eq 'created_t';
+    return $self->_valid_int( $val, $err, $key )  if $key eq 'cost';
+
+    # default case if no test defined for $key: assume invalid
+    $$err = LJ::Lang::ml( 'vgift.error.validate.property', { key => $key } );
+    return 0;
+}
+
+sub _valid_name {
+    my ( $self, $name, $err ) = @_;
+
+    return 1 unless $name;
+    
+    if ( $name !~ /\S/ || $name =~ /[\r\n\t\0]/ ) {
+        $$err = LJ::Lang::ml('vgift.error.validate.name');
+        return 0;
+    }
+    return $self->_valid_text( $name, $err, 'name' );
+}
+
+sub _valid_mime {
+    my ( $self, $mime, $err, $prop ) = @_;
+
+    if ( $mime && $mime !~ /^image\// ) {
+        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
+        return 0;
+    }
+    return 1;
+}
+
+sub _valid_int {
+    my ( $self, $int, $err, $prop ) = @_;
+
+    if ( $int && $int !~ /^\d+$/ ) {
+        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
+        return 0;
+    }
+    return 1;
+}
+
+sub _valid_y_n {
+    my ( $self, $yn, $err, $prop ) = @_;
+
+    if ( $yn && $yn !~ /^[YN]$/i ) {
+        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
+        return 0;
+    }
+    return 1;
+}
+
+sub _valid_uid {
+    my ( $self, $uid, $err, $prop ) = @_;
+
+    if ( defined $uid && ! LJ::load_userid( $uid ) ) {
+        $$err = LJ::Lang::ml( 'vgift.error.validate.value', { prop => $prop } );
+        return 0;
+    }
+    # Not going to check user privileges at this level.
+    # Also, uid 0 ought to be valid (indicates created by "the site").
+    return 1;
+}
+
+sub _valid_text {
+    my ( $self, $text, $err, $prop ) = @_;
+
+    unless ( LJ::text_in( $text ) ) {
+        $$err = LJ::Lang::ml( 'vgift.error.validate.text', { prop => $prop } );
+        return 0;
+    }
+    return 1;
+}
+
+sub validate_priv {
+    my ( $self, $priv ) = @_;
+    return undef unless $priv;
+    my $dbr = LJ::get_db_reader();
+    if ( $priv =~ /^\d+$/ ) {
+        # id->name
+        return $dbr->selectrow_array(
+            'SELECT privcode FROM priv_list WHERE prlid = ?',
+                undef, $priv )
+    } else {
+        # name->id
+        return $dbr->selectrow_array(
+            'SELECT prlid FROM priv_list WHERE privcode = ?',
+                undef, $priv )
+    }
+}
+
+
+# 5. Aggregate methods
+sub _findall {
+    my ( $self, $sql )  = @_;
+    return undef unless $sql;
+    my $dbr = LJ::get_db_reader();
+    my $ids = $dbr->selectcol_arrayref(
+        "SELECT vgiftid FROM vgift_ids WHERE $sql ORDER BY created_t DESC" );
+    die $dbr->errstr if $dbr->err;
+    return undef unless $ids;
+    return map { $self->new( $_ ) } @$ids;
+}
+
+sub _findall_cached {
+    my ( $self, $memkey, $sql )  = @_;
+    return undef unless $sql;
+    return $self->_findall( $sql ) unless $memkey;
+
+    my $data = LJ::MemCache::get( $memkey );
+    return map { $self->new( $_ ) } @$data if $data && ref $data;
+
+    # if it's not in memcache, run the query and update memcache
+    my @vgs = $self->_findall( $sql );
+    my @ids = map { $_->id } @vgs;
+    LJ::MemCache::set( $memkey, \@ids, 24*3600 );
+    return @vgs;
+}
+
+sub list_inactive { $_[0]->_findall( "active='N'" ) }
+
+sub list_queued { $_[0]->_findall( "approved IS NULL AND custom='N'" ) }
+
+sub list_recent {
+    my ( $self, $days ) = @_;
+    return undef unless defined $days && $days =~ /^\d+$/;
+    my $secs = time - $days * 24 * 3600;
+    return $self->_findall( "created_t > $secs AND custom='N'" );
+}
+
+sub list_created_by {
+    my ( $self, $u ) = @_;
+    my $uid = LJ::want_userid( $u );
+    return undef unless $uid;
+    my $memkey = $self->created_by_memkey( $uid );
+    return $self->_findall_cached( $memkey, "creatorid=$uid AND custom='N'" );
+}
+
+sub list_untagged {
+    my ( $self ) = @_;
+    return $self->_findall_cached( $self->untagged_memkey,
+        "custom='N' AND vgiftid NOT IN (SELECT DISTINCT vgiftid FROM vgift_tags)" );
+}
+
+sub list_tagged_with {
+    my ( $self, $tagname ) = @_;
+    return undef if !$tagname || ref $tagname;
+
+    my $tagid = $self->get_tagid( $tagname );
+    return undef unless $tagid;
+
+    my $memkey = $self->tagged_with_memkey( $tagid );
+    my $vgs = LJ::MemCache::get( $memkey ) || [];
+
+    unless ( @$vgs ) {
+        my $dbr = LJ::get_db_reader();
+        $vgs = $dbr->selectcol_arrayref( "SELECT vgiftid FROM vgift_tags " .
+                                         "WHERE tagid=$tagid " .
+                                         "ORDER BY vgiftid DESC" );
+        die $dbr->errstr if $dbr->err;
+        LJ::MemCache::set( $memkey, $vgs, 600 );
+    }
+
+    return map { $self->new( $_ ) } @$vgs;
+}
+
+sub _fetch_tagcounts {
+    my ( $self, $memkey, $select ) = @_;
+    my $counts = LJ::MemCache::get( $memkey ) || {};
+
+    unless ( %$counts ) {
+        my $dbr = LJ::get_db_reader();
+        my $rows = $dbr->selectall_arrayref(
+            "SELECT sk.keyword, COUNT(vt.vgiftid) " .
+            "FROM sitekeywords AS sk, vgift_tags AS vt, vgift_ids AS vi " .
+            "WHERE sk.kwid = vt.tagid AND vi.vgiftid = vt.vgiftid " .
+            "AND vi.$select GROUP BY keyword ORDER BY keyword ASC" );
+        die $dbr->errstr if $dbr->err;
+
+        $counts = { map { $_->[0] => $_->[1] } @$rows };
+
+        # also select from vgift_tagpriv in case we've defined
+        # a privileged tag with no gifts available
+
+        my $privempty = $dbr->selectcol_arrayref(
+            "SELECT keyword FROM sitekeywords WHERE kwid IN " .
+            "(SELECT DISTINCT tagid FROM vgift_tagpriv WHERE tagid NOT IN ".
+            "(SELECT DISTINCT tagid FROM vgift_tags)) ORDER BY keyword ASC" );
+        die $dbr->errstr if $dbr->err;
+        
+        $counts->{$_} = 0 foreach @$privempty;
+
+        LJ::MemCache::set( $memkey, $counts, 24*3600 );
+    }
+
+    return $counts;
+}
+
+sub fetch_tagcounts_approved {
+    my ( $self ) = @_;
+    return $self->_fetch_tagcounts( $self->tagcounts_approved_memkey,
+        "approved='Y'" );
+}
+
+sub fetch_tagcounts_active {
+    my ( $self ) = @_;
+    return $self->_fetch_tagcounts( $self->tagcounts_active_memkey,
+        "active='Y'" );
+}
+
+sub fetch_creatorcounts {
+    my ( $self, $type ) = @_;
+    my $memkey = $self->creatorcounts_memkey;
+    my $counts = LJ::MemCache::get( $memkey ) || {};
+
+    unless ( %$counts ) {
+        my $dbr = LJ::get_db_reader();
+        my $ids = $dbr->selectcol_arrayref(
+            "SELECT DISTINCT creatorid FROM vgift_ids WHERE custom='N'" );
+        die $dbr->errstr if $dbr->err;
+
+        foreach my $uid ( @$ids ) {
+            $counts->{$uid}->{active} = 0;
+            $counts->{$uid}->{approved} = 0;
+
+            foreach my $vg ( $self->list_created_by( $uid ) ) {
+                $counts->{$uid}->{active}++ if $vg->is_active;
+                $counts->{$uid}->{approved}++ if $vg->is_approved;
+            }
+        }
+
+        LJ::MemCache::set( $memkey, $counts, 24*3600 );
+    }
+
+    return { map { $_ => $counts->{$_}->{$type} } keys %$counts }
+        if $type && $type =~ /^(active|approved)$/;
+    return $counts;
+}
+
+sub list_nonpriv_tags {
+    my ( $self ) = @_;
+    my $memkey = $self->nonpriv_tags_memkey;
+    my $names = LJ::MemCache::get( $memkey ) || [];
+
+    unless ( @$names ) {
+        my $dbr = LJ::get_db_reader();
+        $names = $dbr->selectcol_arrayref(
+            "SELECT keyword FROM sitekeywords WHERE kwid IN " .
+            "(SELECT DISTINCT tagid FROM vgift_tags WHERE tagid NOT IN ".
+            "(SELECT DISTINCT tagid FROM vgift_tagpriv)) ORDER BY keyword ASC"
+            );
+        die $dbr->errstr if $dbr->err;
+
+        LJ::MemCache::set( $memkey, $names, 24*3600 );
+    }
+    return wantarray ? @$names : $names;
+}
+
+sub list_tagprivs {
+    my ( $self, $tagname ) = @_;
+    return undef if !$tagname || ref $tagname;
+
+    my $tagid = $self->get_tagid( $tagname );
+    return undef unless $tagid;
+
+    my $dbr = LJ::get_db_reader();
+    my $rows = $dbr->selectall_arrayref( "SELECT pl.privcode, tp.arg FROM "
+                                       . "priv_list AS pl, vgift_tagpriv AS tp "
+                                       . "WHERE tp.tagid=$tagid AND "
+                                       . "pl.prlid=tp.prlid "
+                                       . "ORDER BY privcode ASC, arg ASC" );
+    die $dbr->errstr if $dbr->err;
+
+    return @$rows;
+}
+
+
+# 6. End-user display methods
+sub display_basic {
+    my $self = shift;
+    my $id = $self->id or return undef;
+    my $ret = '';
+
+    $ret .= "<h2>" . $self->name_ehtml . " (#" . $self->id . ")</h2><p><b>";
+    $ret .= LJ::Lang::ml( 'vgift.display.createdby',
+                     { user => $self->creator->ljuser_display,
+                       ago => $self->created_ago_text } );
+    $ret .= "</b></p>\n" . $self->img_small_html;
+    $ret .= "<p>" . $self->description_ehtml . "</p>\n";
+    $ret .= "<p><b>" . LJ::Lang::ml( 'vgift.display.label.tags' ) . "</b> ";
+    $ret .= $self->display_taglist . "</p>\n";
+
+    return $ret;
+}
+
+sub display_summary {
+    my $self = shift;
+    my $id = $self->id or return undef;
+    my $ret = '';
+
+    $ret .= "<div style='clear: left'></div>\n";
+    $ret .= "<div style='float: left; margin-right: 2em'>";
+    $ret .= $self->img_small_html;
+    $ret .= "</div><p><b>";
+    $ret .= $self->name_ehtml . '</b>: <em>' . $self->description_ehtml;
+    $ret .= "</em><br />";
+    $ret .= LJ::Lang::ml( 'vgift.display.createdby',
+                     { user => $self->creator->ljuser_display,
+                       ago => $self->created_ago_text } );
+    $ret .= "<br />" . LJ::Lang::ml( 'vgift.display.label.cost' ) . " ";
+    $ret .= $self->display_cost . "</p>";
+
+    return $ret;
+}
+
+sub display_taglist {
+    # reverse of tags method: take in arrayref, return string
+    my ( $self, $tags ) = @_;
+    $tags = $self->tags unless $tags && ref $tags eq 'ARRAY';
+    return LJ::ehtml( join( ', ', sort { $a cmp $b } @$tags ) );
+}
+
+sub display_cost {
+    my ( $self, $cost ) = @_;
+    $cost ||= $self->cost unless $self->is_free;
+    return $cost ? LJ::Lang::ml( 'vgift.display.cost.points', { cost => $cost } )
+                 : LJ::Lang::ml( 'vgift.display.cost.free' );
+}
+
+sub display_vieweditlinks {
+    my ( $self, $review ) = @_;
+    my $id = $self->id or return '';
+    my $linkroot = "$LJ::SITEROOT/admin/vgifts/";
+    my %modes = ( view => LJ::Lang::ml('vgift.display.linktext.viewedit'),
+                  review => LJ::Lang::ml('vgift.display.linktext.review'),
+                  delete => LJ::Lang::ml('vgift.display.linktext.delete'),
+                );
+    delete $modes{review} unless $review;
+    delete $modes{delete} if $self->is_active;
+
+    my $text = "";
+    foreach my $mode qw( view review delete ) {
+        next unless $modes{$mode};
+        $text .= ' | ' if $text;
+        $text .= "<a href='$linkroot?mode=$mode&id=$id'>$modes{$mode}</a>";
+    }
+    return $text;
+}
+
+sub display_viewbylink {
+    my ( $self, $uid ) = @_;
+    my $u = LJ::want_user( $uid ) or return '';
+    my $user = $u->user;
+    my $linkroot = "$LJ::SITEROOT/admin/vgifts/";
+    return " <a href='$linkroot?mode=view&user=$user'>"
+         . LJ::Lang::ml('vgift.display.linktext.viewgifts') . "</a>";
+}
+
+sub display_creatorlist {
+    my ( $self, $num ) = @_;
+    my $data = $self->fetch_creatorcounts;
+    my $users = LJ::load_userids( keys %$data );
+    my $sort = sub {
+        $data->{$b}->{active} <=> $data->{$a}->{active} ||
+        $data->{$b}->{approved} <=> $data->{$a}->{approved} ||
+        $users->{$a}->user cmp $users->{$b}->user };
+    my @creatorlist = map { [ $users->{$_}, $data->{$_}->{approved},
+            $data->{$_}->{active} ] } sort $sort keys %$data;
+    my @printlist;
+
+    foreach ( @creatorlist ) {
+        last if $num && $num == scalar @printlist;
+        my ( $u, $approved, $active ) = @$_;
+        my $text = '<li>';
+        $text .= LJ::Lang::ml( 'vgift.display.creatorlist.counts',
+            { user => $u->ljuser_display,
+              approved => $approved,
+              active => $active } );
+        $text .= $self->display_viewbylink( $u ) . "</li>\n";
+        push @printlist, $text;
+    }
+    return join '', @printlist;
+}
+
+
+# 7. Notification methods
+sub notify_approved {
+    my ( $self, $id ) = @_;
+
+    if ( $id ) {  # transform class method -> object method
+        $self = $self->new( $id ) or return;
+    } else {  # verify object
+        $id = $self->id or return;
+    }
+    # make sure the gift was actually reviewed
+    return if $self->is_queued;
+
+    # notify the user (inbox only, no opt-out)
+    my @args = ( $self->creator, $self->approver, $self );
+    LJ::Event::VgiftApproved->new( @args )->fire;
+}
+
+
+1;
diff -r 4c7678ebddbf -r c4063e1602c6 cgi-bin/LJ/Event/VgiftApproved.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/LJ/Event/VgiftApproved.pm	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,149 @@
+#!/usr/bin/perl
+#
+# LJ::Event::VgiftApproved
+#
+# Event for approving a virtual gift
+#
+# Authors:
+#      Jen Griffin <kareila@livejournal.com>
+#
+# Copyright (c) 2010 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself. For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+
+package LJ::Event::VgiftApproved;
+use strict;
+use base 'LJ::Event';
+use Carp qw(croak);
+use DW::VirtualGift;
+
+sub new {
+    my ( $class, $u, $fromu, $vgift ) = @_;
+
+    croak 'Not to an LJ::User' unless LJ::isu( $u );
+    croak 'Not from an LJ::User' unless LJ::isu( $fromu );
+
+    $vgift = DW::VirtualGift->new( $vgift ) unless ref $vgift;
+    croak 'Invalid vgift' unless $vgift && $vgift->name;
+
+    return $class->SUPER::new( $u, $fromu->id, $vgift->id );
+}
+
+# access args
+sub fromuid { return $_[0]->arg1 }
+
+sub vgiftid { return $_[0]->arg2 }
+
+sub fromu {
+    my ( $self ) = @_;
+    $self->{fromu} = LJ::load_userid( $self->fromuid )
+        unless $self->{fromu};
+    return $self->{fromu};
+}
+
+sub vgift {
+    my ( $self ) = @_;
+    $self->{vgift} = DW::VirtualGift->new( $self->vgiftid )
+        unless $self->{vgift};
+    return $self->{vgift};
+}
+
+# message content
+sub _summary {
+    my ( $self, $admin ) = @_;
+    return BML::ml( 'event.vgift.notfound' )
+        unless $self->vgift && $self->vgift->name;
+    my $yn = $self->vgift->approved;
+    # event.vgift.approved.content.Y = thumbs up
+    # event.vgift.approved.content.N = thumbs down
+    return BML::ml( "event.vgift.approved.content.$yn",
+                    { vgift => $self->vgift->name_ehtml,
+                      admin => $admin } );
+}
+
+sub as_string { return $_[0]->_summary( $_[0]->fromu->display_username ) }
+
+sub as_html { return $_[0]->_summary( $_[0]->fromu->ljuser_display ) }
+
+sub as_html_actions {
+    my ( $self ) = @_;
+    my $url = "$LJ::SITEROOT/admin/vgifts/?mode=view&id=" . $self->vgiftid;
+    my $ret = "<div class='actions'>";
+    $ret .= BML::ml( 'event.vgift.approved.actions',
+                     { aopts => "href='$url'" } );
+    $ret .= "</div>\n";
+
+    return $ret;
+}
+
+sub content_summary { return $_[0]->as_html }
+
+sub content {
+    my ( $self ) = @_;
+    return BML::ml( 'event.vgift.notfound' )
+        unless $self->vgift && $self->vgift->name;
+    my $yn = $self->vgift->approved;
+    my $ret = '<p>';
+    $ret .= BML::ml( "event.vgift.approved.msg.$yn",
+                     { vgift => $self->vgift->name_ehtml } ) . '</p>';
+    if ( $self->vgift && $self->vgift->approved_why ) {
+        my $reason = LJ::ehtml( $self->vgift->approved_why );
+        my $mltext = BML::ml( 'event.vgift.approved.reason',
+                              { admin => $self->fromu->ljuser_display } );
+        $ret .= "<p>$mltext</p><p><q>$reason</q></p>\n";
+    }
+    $ret .= $self->as_html_actions;
+
+    return $ret;
+}
+
+# subscriptions are always on, can't be turned off
+sub is_common { 1 }
+
+sub is_visible { 0 }
+
+sub always_checked { 1 }
+
+# override parent class subscription methods to always return
+# a subscription object for the user - copied from LJ::Event::XPostSuccess
+sub subscriptions {
+    my ( $self, %args ) = @_;
+    my $cid   = delete $args{'cluster'};  # optional
+    my $limit = delete $args{'limit'};    # optional
+    croak("Unknown options: " . join(', ', keys %args)) if %args;
+    croak("Can't call in web context") if LJ::is_web_context();
+
+    my @subs;
+    my $u = $self->u;
+    return unless $cid == $u->clusterid;
+
+    my $row = { userid  => $self->u->id,
+                ntypeid => LJ::NotificationMethod::Inbox->ntypeid, # Inbox
+              };
+
+    push @subs, LJ::Subscription->new_from_row($row);
+
+    push @subs, eval { $self->SUPER::subscriptions(cluster => $cid,
+                                                   limit   => $limit) };
+
+    return @subs;
+}
+
+sub get_subscriptions {
+    my ( $self, $u, $subid ) = @_;
+
+    unless ($subid) {
+        my $row = { userid  => $u->{userid},
+                    ntypeid => LJ::NotificationMethod::Inbox->ntypeid, # Inbox
+                  };
+
+        return LJ::Subscription->new_from_row($row);
+    }
+
+    return $self->SUPER::get_subscriptions($u, $subid);
+}
+
+
+1;
diff -r 4c7678ebddbf -r c4063e1602c6 cgi-bin/ljdefaults.pl
--- a/cgi-bin/ljdefaults.pl	Thu Oct 28 11:15:07 2010 +0800
+++ b/cgi-bin/ljdefaults.pl	Thu Oct 28 12:23:03 2010 +0800
@@ -186,6 +186,7 @@ no strict "vars";
     $MOGILEFS_CONFIG{classes}                ||= {};
     $MOGILEFS_CONFIG{classes}->{temp}        ||= 2;
     $MOGILEFS_CONFIG{classes}->{userpics}    ||= 3;
+    $MOGILEFS_CONFIG{classes}->{vgifts}      ||= 3;
 
     # Default to allow all reproxying.
     %REPROXY_DISABLE = () unless %REPROXY_DISABLE;
@@ -220,6 +221,7 @@ no strict "vars";
                                NewUserpic
                                PollVote
                                UserExpunged
+                               VgiftApproved
                                );
         foreach my $evt (@LJ::EVENT_TYPES) {
             $evt = "LJ::Event::$evt";
diff -r 4c7678ebddbf -r c4063e1602c6 cgi-bin/ljlib.pl
--- a/cgi-bin/ljlib.pl	Thu Oct 28 11:15:07 2010 +0800
+++ b/cgi-bin/ljlib.pl	Thu Oct 28 12:23:03 2010 +0800
@@ -2019,7 +2019,7 @@ sub get_secret
 #
 # LJ-generic domains:
 #  $dom: 'S' == style, 'P' == userpic, 'A' == stock support answer
-#        'E' == external user,
+#        'E' == external user, 'V' == vgifts,
 #        'L' == poLL,  'M' == Messaging, 'H' == sHopping cart,
 #        'F' == PubSubHubbub subscription id (F for Fred),
 #        'K' == sitekeyword, 'I' == shopping cart Item
@@ -2032,7 +2032,7 @@ sub alloc_global_counter
 
     # $dom can come as a direct argument or as a string to be mapped via hook
     my $dom_unmod = $dom;
-    unless ( $dom =~ /^[ESLPAHCMFKI]$/ ) {
+    unless ( $dom =~ /^[ESLPAHCMFKIV]$/ ) {
         $dom = LJ::Hooks::run_hook('map_global_counter_domain', $dom);
     }
     return LJ::errobj("InvalidParameters", params => { dom => $dom_unmod })->cond_throw
@@ -2073,6 +2073,8 @@ sub alloc_global_counter
         $newmax = $dbh->selectrow_array( "SELECT MAX(pollid) FROM pollowner" );
     } elsif ( $dom eq 'F' ) {
         $newmax = $dbh->selectrow_array( 'SELECT MAX(id) FROM syndicated_hubbub2' );
+    } elsif ( $dom eq 'V' ) {
+        $newmax = $dbh->selectrow_array( "SELECT MAX(vgiftid) FROM vgift_ids" );
     } elsif ( $dom eq 'K' ) {
         # pick maximum id from sitekeywords & interests
         my $max_sitekeys  = $dbh->selectrow_array( "SELECT MAX(kwid) FROM sitekeywords" );
diff -r 4c7678ebddbf -r c4063e1602c6 htdocs/admin/vgifts/inactive.bml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/admin/vgifts/inactive.bml	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,302 @@
+<?_code
+{
+    use strict;
+    use vars qw($title $body $page $action %GET %POST);
+
+    use DW::VirtualGift;
+
+    $title = $ML{'.title'};
+    $body = "";
+    $page = "$LJ::SITEROOT/admin/vgifts/inactive";
+    $action = 'inactive';  # for $postform
+
+    # helper routines
+
+    my $error = sub { 
+        $title = $ML{'.error'};
+        $body = join '', @_;
+        return undef;
+    };
+    my $errmsg;
+
+    my $loose_refer = sub {
+        return 1 unless my $refer = BML::get_client_header('Referer');
+        my ( $getargs ) = ( $refer =~ m/\?(.*)$/ );
+        return LJ::check_referer( $page ) unless $getargs;
+        return LJ::check_referer( "$page?$getargs" );
+    };
+
+    my $strict_refer = sub {
+        # make sure we have a referer header. check_referer doesn't care.
+        return BML::get_client_header('Referer') && $loose_refer->();
+    };
+
+    my $textform = sub {
+        my ( $name, $ml, $optref ) = @_;
+        my %opts = $optref ? %$optref : ();
+        my $text = "\n<label for='$name'>$ML{$ml}</label>";
+        $text .= '<br />' and delete $opts{br} if $opts{br}; 
+        $text .= LJ::html_text( { name => $name, id => $name, %opts } );
+        return $text;
+    };
+
+    my $checkid = sub {
+        my $id = shift;
+        return $error->( $ML{'error.invalidform'} ) unless $id;
+
+        my $vgift = DW::VirtualGift->new( $id );
+        return $error->( $ML{'.error.badid'} )
+            unless $vgift && $vgift->name;
+
+        return $vgift;
+    };
+
+    my $postform = sub {
+        my ( $mode, $id ) = @_;
+        my $text = "<form action='$action' method='post'>";
+        $text .= LJ::html_hidden( mode => $mode ) if $mode;
+        $text .= LJ::html_hidden( id => $id ) if $id && $id =~ /^\d+$/;
+        return $text;
+    };
+
+    my $endform = sub {
+        my ( $submit ) = @_;
+        my $text = '<p>';
+        $text .= LJ::html_submit( submit => $submit ) ;
+        $text .= '</p></form>';
+        return $text;
+    };
+
+    my $select_tabs = sub {
+        my ( $mode ) = @_;
+        my @modes = ( '', 'tags' );  # ordered
+        my %tabs = (
+                '' => $ML{'.tab.default'},
+            'tags' => $ML{'.tab.tags'},
+        );
+        return '' unless $tabs{$mode};
+
+        my $text = "<li><b>$ML{'.header.tabs'}</b></li>";
+
+        foreach ( @modes ) {
+            next if $_ eq $mode;
+            my $href = $_ ? "?mode=$_" : $page;
+            $text .= "<li><a href='$href'>$tabs{$_}</a></li>";
+        }
+        return "<ul class='tabs'>$text</ul><div style='clear: both'></div>";
+    };
+
+    my %nonpriv = map { $_ => 1 } DW::VirtualGift->list_nonpriv_tags;
+    my $display_privtags = sub {
+        my $vg = shift;
+        my @print_tags;
+        my $privtext = '';
+        foreach ( sort { $a cmp $b } $vg->tags ) {
+            # linkify
+            my $tagname = LJ::eurl( $_ );
+            my $txt = LJ::ehtml( $_ );
+            $txt = "<a href='tags?mode=view&tag=$tagname'>$txt</a>";
+            unless ( $nonpriv{$_} ) {
+                # asterisk tags with privileges
+                $txt .= ' [*]';
+                $privtext = $ML{'.note.privstar'};
+            }
+            push @print_tags, $txt;
+        }
+        return '' unless @print_tags;
+        return "$ML{'vgift.display.label.tags'} " .
+            join( ', ', @print_tags ) . " &nbsp; $privtext";
+    };
+
+    my $display_gift = sub {
+        my $vg = shift;
+        my $id = $vg->id;
+        my $text = "<div style='margin: 1em 0 2em 0'>";
+        my $check = "${id}_activate";
+        $text .= LJ::html_check( { name => $check, id => $check,
+                                   value => 1 } );
+        $text .= " <label for='$check'>$ML{'.label.activate'}</label>\n";
+        $text .= LJ::html_hidden( "${id}_chksum" => $vg->checksum );
+        $text .= $vg->display_summary . $display_privtags->( $vg );
+        $text .= "<p><a href='/admin/vgifts/?mode=review&title=inactive&id=$id'>";
+        $text .= BML::ml( '.linktext.edit', { name => $vg->name_ehtml } );
+        $text .= "</a></p></div>";
+        return $text;
+    };
+
+    # end helper routines
+
+    # login check
+    my $remote = LJ::get_remote();
+    return $error->( "<?needlogin?>" ) unless $remote;
+
+    # priv check
+    my @displayprivs = ( "siteadmin:vgifts" );
+    my $siteadmin = $remote->has_priv( 'siteadmin', 'vgifts' ) || $LJ::IS_DEV_SERVER;
+
+    return $error->( BML::ml( "admin.noprivserror",
+                     { numprivs => scalar @displayprivs,
+                       needprivs => "<b>" . join(", ", @displayprivs) . "</b>"
+                     } ) )
+        unless $siteadmin;
+
+    # mode check
+    my $mode = lc( $GET{mode} || $POST{mode} );
+    my $auth = LJ::form_auth();
+    my $linkhome = "<p style='clear: both'><a href='/admin/vgifts/'>"
+                 . "$ML{'.linktext.home'}</a></p>\n";
+
+    if ( LJ::did_post() && $mode ) {
+        return $error->( $ML{'error.invalidform'} )
+            unless LJ::check_form_auth();
+        $mode = '' unless $loose_refer->();
+
+        if ( $mode eq 'activate' ) {
+            my @vgs;
+            foreach ( keys %POST ) {
+                my ( $id ) = ( $_ =~ /^(\d+)_activate$/ );
+                next unless $id;
+                my $vg = $checkid->( $id ) or return;
+                next if $vg->is_active;  # already active
+
+                return $error->( BML::ml( '.error.notags',
+                                          { name => $vg->name_ehtml } ) )
+                    if $vg->is_untagged;
+                return $error->( BML::ml( '.error.changed',
+                                          { name => $vg->name_ehtml } ) )
+                    if $POST{"${id}_chksum"} ne $vg->checksum;
+
+                push @vgs, $vg;
+            }
+            # now that we're clear of possible errors, do the activation
+            $_->mark_active foreach @vgs;
+            my $ids = join ', ', map { $_->id } @vgs;
+            LJ::statushistory_add( 0, $remote, 'vgifts', "Activated: $ids" )
+                if $ids;
+
+            # go back to where we were
+            $body = BML::redirect( BML::get_client_header('Referer') );
+
+        } else {
+            # if we get here, check_referer failed or something weird happened
+            $body = BML::redirect( $page );
+        }
+
+        return;
+    }
+
+    # non post processing stuff (check for gets)
+
+    # selection tabs go here: List All, Filter By Tag, etc.
+    $body .= $select_tabs->( $mode );
+
+    my @vgs;
+
+    if ( $mode eq 'tags' ) {
+        my $tag = $GET{tag} || '';
+
+        if ( $tag ) {
+            my $id = DW::VirtualGift->get_tagid( $tag )
+                or return $error->( $ML{'.error.badid'} );
+            @vgs = DW::VirtualGift->list_tagged_with( $tag );
+            # list_tagged_with includes active gifts
+            @vgs = grep { $_->is_inactive } @vgs;
+        } else {
+            @vgs = DW::VirtualGift->list_untagged;
+        }
+
+        $body .= "<div class='vgdiv' id='right'>";
+        $body .= "<h2>$ML{'.header.tagfilter'} ";
+        $body .= $tag ? LJ::ehtml( $tag ) : $ML{'.label.untagged'};
+        $body .= "</h2>";
+
+        my $app = DW::VirtualGift->fetch_tagcounts_approved;
+        my $act = DW::VirtualGift->fetch_tagcounts_active;
+        my %approved_inactive;
+        $approved_inactive{$_} = $app->{$_} - $act->{$_}
+            foreach keys %$app;
+
+        if ( %approved_inactive ) {
+            $body .= "<ul style='padding-left: 2em'>";
+            if ( $tag ) {
+                # show entry for untagged filter
+                my $count = scalar grep { $_->is_approved }
+                            DW::VirtualGift->list_untagged;
+                if ( $count ) {
+                    $body .= "<li><a href='?mode=tags'>";
+                    $body .= $ML{'.label.untagged'};
+                    $body .= "</a> ($count)</li>";
+                }
+            }
+            foreach ( keys %approved_inactive ) {
+                next if $_ eq $tag;
+                my $count = $approved_inactive{$_} or next;
+                my $tagname = LJ::eurl( $_ );
+                $body .= "<li><a href='?mode=tags&tag=$tagname'>";
+                $body .= LJ::ehtml( $_ );
+                $body .= "</a> ($count)</li>";
+            }
+            $body .= "</ul>";
+        }
+
+        $body .= "</div>";
+
+    } else {
+        # DEFAULT PAGE DISPLAY
+        @vgs = DW::VirtualGift->list_inactive;
+    }
+
+    # don't include queued or rejected gifts
+    @vgs = grep { $_->is_approved } @vgs;
+
+    my @feat = grep { $_->is_featured } @vgs;
+
+    $body .= $postform->( 'activate' ) . $auth;
+
+    $body .= "<h2>$ML{'.header.featured'}</h2>\n";
+    my $text = '';
+    foreach my $vg ( @feat ) {
+        $text .= $display_gift->( $vg );
+    }
+    $body .= $text ? "<ul>$text</ul>" : $ML{'.queue.empty'};
+    $body .= "<div style='clear: left'></div>\n";
+
+    $body .= "<h2>$ML{'.header.nonfeatured'}</h2>\n";
+    $text = '';
+    foreach my $vg ( @vgs ) {
+        next if $vg->is_featured;
+        $text .= $display_gift->( $vg );
+    }
+    $body .= $text ? "<ul>$text</ul>" : $ML{'.queue.empty'};
+    $body .= $endform->( $ML{'.submit.activate'} );
+    $body .= "<div style='margin-bottom: 15px'></div>\n";
+
+    $body .= $linkhome;
+
+    return;
+}
+_code?><?page
+title=><?_code return $title; _code?>
+body=><?_code return $body; _code?>
+head<=
+<style type="text/css">
+label       { margin-right: 1em; }
+.vgdiv      { margin-bottom: 15px; width: 45%; }
+#content p  { margin-top: 1em; }
+#content h2 { margin-top: 1em; }
+#content h3 { margin: 0.5em 0; }
+#content li { margin: 0.25em 0; }
+#content ol { list-style: decimal inside; }
+#left       { float: left; }
+#right      { float: right; }
+ul.tabs { width: 100%; padding-bottom: 3.5em; }
+ul.tabs li {
+    float: left;
+    display: block;
+    padding-right: 1em;
+    font-size: large;
+}
+ul.tabs li:first-child { padding-right: 0.5em; }
+</style>
+<=head
+page?>
diff -r 4c7678ebddbf -r c4063e1602c6 htdocs/admin/vgifts/inactive.bml.text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/admin/vgifts/inactive.bml.text	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,37 @@
+.error=Error
+
+.error.badid=The specified tag was not found.
+
+.error.changed=The information for "[[name]]" has changed. Please reload and try again.
+
+.error.notags=The gift "[[name]]" cannot be activated because it has no tags.
+
+.error.upload.noheader=No content-length header: can't upload
+
+.header.featured=Featured Gifts:
+
+.header.nonfeatured=Other Available Gifts:
+
+.header.tabs=Other Views:
+
+.header.tagfilter=Currently viewing:
+
+.label.activate=Activate the following gift:
+
+.label.untagged=Untagged
+
+.linktext.edit=Edit [[name]]'s featured status, cost, or tags.
+
+.linktext.home=Back to Virtual Gifts main page
+
+.note.privstar=(Note: [*] indicates a privileged tag)
+
+.queue.empty=(none available)
+
+.submit.activate=Activate Selected Gifts
+
+.tab.default=List All
+
+.tab.tags=Filter By Tag
+
+.title=Virtual Gifts: Activation Management
diff -r 4c7678ebddbf -r c4063e1602c6 htdocs/admin/vgifts/index.bml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/admin/vgifts/index.bml	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,673 @@
+<?_code
+{
+    use strict;
+    use vars qw($title $body $page $action %GET %POST);
+
+    use DW::VirtualGift;
+
+    $title = $ML{'.title'};
+    $body = "";
+    $page = "$LJ::SITEROOT/admin/vgifts/";
+    $action = 'index';  # for $postform
+
+    # helper routines
+
+    my $error = sub { 
+        $title = $ML{'.error'};
+        $body = join '', @_;
+        return undef;
+    };
+    my $errmsg;
+
+    my $loose_refer = sub {
+        return 1 unless my $refer = BML::get_client_header('Referer');
+        my ( $getargs ) = ( $refer =~ m/\?(.*)$/ );
+        # annoyingly, we get different results for /index vs /
+        return LJ::check_referer( $page ) ||
+            LJ::check_referer( "${page}index" ) unless $getargs;
+
+        return LJ::check_referer( "$page?$getargs" ) ||
+            LJ::check_referer( "${page}index?$getargs" );
+    };
+
+    my $strict_refer = sub {
+        # make sure we have a referer header. check_referer doesn't care.
+        return BML::get_client_header('Referer') && $loose_refer->();
+    };
+
+    my $imgposted = sub {
+        my $id = shift;
+        return length ( $POST{"data_$id"} ) || length ( $POST{"url_$id"} );
+    };
+
+    my $imgform = sub {
+        my $id = shift;
+        my $checkfile = LJ::html_check( { type => 'radio', value => 'file',
+                                          name => $id, id => "${id}_file",
+                                          selected => '1',
+                                          accesskey => $ML{'.label.fromfile.key'}
+                                        } );
+        my $checkurl =  LJ::html_check( { type => 'radio', value => 'url',
+                                          name => $id, id => "${id}_url",
+                                          accesskey => $ML{'.label.fromurl.key'}
+                                        } );
+        my $text = qq:
+            $checkfile
+            <label for='${id}_file'>$ML{'.label.fromfile'}</label>
+            <input type='file' name='data_$id' size='25' /><br />
+            $checkurl
+            <label for='${id}_url'>$ML{'.label.fromurl'}</label>
+            :;
+        $text .= LJ::html_text( { name => "url_$id", size => 25 } ) . "\n";
+        return $text;
+    };
+
+    my $loadpic = sub {
+        my ( $id, $loaderr ) = @_;
+        my $data;
+
+        unless ( LJ::mogclient() ) {
+            $$loaderr = $ML{'.error.upload.nomogilefs'};
+            return undef;
+        }
+
+        if ( $POST{$id} eq 'url' ) {
+            $data = $POST{"url_$id"};
+
+            if ( length( $data ) == 0 ) {
+                $$loaderr = $ML{'.error.upload.nourl'};
+            } elsif ( $data !~ m!^http://! ) {
+                $$loaderr = $ML{'.error.upload.badurl'};
+            } else {
+                my $ua = LJ::get_useragent( role => 'vgift' );
+                my $res = $ua->get( $data );
+                if ( $res && $res->is_success ) {
+                    $data = $res->content;
+                } else {
+                    $$loaderr = $ML{'.error.upload.urlerror'};
+                }
+            }
+        } elsif ( $POST{$id} eq 'file' ) {
+            $data = $POST{"data_$id"};
+            $$loaderr = $ML{'.error.upload.nofile'} unless length( $data );
+        } else {
+            $$loaderr = $ML{'error.invalidform'};
+        }
+
+        return undef if $$loaderr;
+
+        # further processing
+        my ( $width, $height, $filetype ) = Image::Size::imgsize( \$data );
+        unless ( $width && $height ) {
+            $$loaderr = BML::ml( '.error.upload.badtype',
+                                { filetype => $filetype } );
+        } elsif ( ( $width > 100 || $height > 100 ) && $id eq 'img_small' ) {
+            $$loaderr = BML::ml( '.error.upload.dimstoolarge',
+                                { imagesize => "${width}x$height",
+                                  maxsize => "100x100" } );
+        } elsif ( ( $width > 300 || $height > 300 ) && $id eq 'img_large' ) {
+            $$loaderr = BML::ml( '.error.upload.dimstoolarge',
+                                { imagesize => "${width}x$height",
+                                  maxsize => "300x300" } );
+        } elsif ( length( $data ) > 250 * 1024 ) {  # 250KB (arbitrary)
+            $$loaderr = BML::ml( '.error.upload.filetoolarge',
+                                { maxsize => "250" } );
+        } else {
+            # data should be good, return a reference to it
+            return \$data;
+        }
+        return undef;  # check loaderr to see what went wrong
+    };
+
+    my $textform = sub {
+        my ( $name, $ml, $optref ) = @_;
+        my %opts = $optref ? %$optref : ();
+        my $text = "\n<label for='$name'>$ML{$ml}</label>";
+        $text .= '<br />' and delete $opts{br} if $opts{br};
+
+        my $use_area = $opts{textarea} ? 1 : 0;
+        delete $opts{textarea};
+        my $htargs = { name => $name, id => $name, %opts };
+        $text .= $use_area ? LJ::html_textarea( $htargs )
+                           : LJ::html_text( $htargs );
+        return $text;
+    };
+
+    my $checkid = sub {
+        my $id = shift;
+        return $error->( $ML{'error.invalidform'} ) unless $id;
+
+        my $vgift = DW::VirtualGift->new( $id );
+        return $error->( $ML{'.error.badid'} )
+            unless $vgift && $vgift->name;
+
+        return $vgift;
+    };
+
+    my $postform = sub {
+        my ( $mode, $id ) = @_;
+        my $text = "<form enctype='multipart/form-data' action='$action' method='post'>";
+        $text .= LJ::html_hidden( mode => $mode ) if $mode;
+        $text .= LJ::html_hidden( id => $id ) if $id && $id =~ /^\d+$/;
+        return $text;
+    };
+
+    my $endform = sub {
+        my ( $submit ) = @_;
+        my $text = '<p>';
+        $text .= LJ::html_submit( submit => $submit ) ;
+        $text .= '</p></form>';
+        return $text;
+    };
+
+    my $shopform = sub {
+        my ( $vg ) = @_;
+        my $id = $vg->id;
+        my $feat = $vg->featured;
+        my $cost = $vg->cost ? $vg->cost : '';
+        my $tags = $vg->display_taglist;
+        my $text = "";
+
+        $text .= "<label for='${id}_featured'>";
+        $text .= $ML{'vgift.display.label.featured'} . '</label>';
+        $text .= LJ::html_select( { name => "${id}_featured",
+                                    selected => $feat },
+                                  'N' => $ML{'.label.review.answer.n'},
+                                  'Y' => $ML{'.label.review.answer.y'},
+                                );
+        $text .= '<br />';
+        $text .= $textform->( "${id}_cost", 'vgift.display.label.cost',
+                              { size => 5, value => $cost } );
+        $text .= BML::ml( 'vgift.display.cost.points',
+                          { cost => '' } ) . '<br />';
+        $text .= $textform->( "${id}_tags", 'vgift.display.label.tags',
+                              { size => 50, value => $tags } );
+        return $text;
+    };
+    
+    my $begin_reviewdiv_left = sub {
+        return "<div style='clear: both'><div class='vgdiv' id='left'>";
+    };
+
+    my $begin_reviewdiv_right = sub {
+        return "</div><div class='vgdiv' id='right'>";
+    };
+
+    my $end_reviewdiv = sub {
+        return "</div></div>\n";
+    };
+
+    my $review_status = sub {
+        my $vg = shift;
+        my $text = "";
+        return $text unless $vg->id && ! $vg->is_queued;
+
+        $text .= "<h3>";
+        $text .= $vg->is_approved
+                   ? $ML{'.label.review.approved'}
+                   : $ML{'.label.review.rejected'};
+        $text .= $vg->approver->ljuser_display;
+        $text .= "</h3><p><b>$ML{'.label.review.why'}</b> ";
+        $text .= LJ::ehtml( $vg->approved_why );
+        $text .= "</p>\n";
+
+        return $text;
+    };
+
+    my $shop_status = sub {
+        my $vg = shift;
+        my $text = "";
+        return $text unless $vg->id && $vg->is_approved;
+
+        $text .= "<p><b>$ML{'vgift.display.label.featured'}</b> ";
+        $text .= $vg->is_featured
+                   ? $ML{'.label.review.answer.y'}
+                   : $ML{'.label.review.answer.n'};
+        $text .= "</p><p><b>$ML{'vgift.display.label.cost'}</b> ";
+        $text .= $vg->display_cost . "</p>";
+        # tags are included in display_basic
+
+        return $text;
+    };
+
+    my $review_display = sub {
+        my $vgift = shift;
+        my $status = "";
+        $status .= $review_status->( $vgift );
+        $status .= $shop_status->( $vgift );
+        $status = "<h2>$ML{'.header.review'}</h2>$status" if $status;
+        return $status;
+    };
+
+    my $userview = sub {
+        my ( $u, $review ) = @_;
+        LJ::isu( $u ) or return;
+        $title .= ": " . $u->display_name;
+        my $text = "";
+        foreach my $vg ( DW::VirtualGift->list_created_by( $u ) ) {
+            next unless $vg;
+            my $r = $review && $vg->is_queued;
+            $text .= $begin_reviewdiv_left->();
+            $text .= $vg->display_basic;
+            $text .= "<p>" . $vg->display_vieweditlinks( $r ) . "</p>\n";
+            $text .= $begin_reviewdiv_right->();
+            $text .= $review_display->( $vg );
+            $text .= $end_reviewdiv->();
+        }
+        $body .= $text ? $text : "<p>$ML{'.review.empty'}</p>\n";
+    };
+
+    # end helper routines
+
+    # login check
+    my $remote = LJ::get_remote();
+    return $error->( "<?needlogin?>" ) unless $remote;
+
+    # priv check
+    my @displayprivs = ( "vgifts", "siteadmin:vgifts" );
+    my $siteadmin = $remote->has_priv( 'siteadmin', 'vgifts' ) || $LJ::IS_DEV_SERVER;
+
+    return $error->( BML::ml( "admin.noprivserror",
+                     { numprivs => scalar @displayprivs,
+                       needprivs => "<b>" . join(", ", @displayprivs) . "</b>"
+                     } ) )
+        unless $remote->has_priv( 'vgifts' ) || $siteadmin;
+
+    # process multipart form
+    if ( LJ::did_post() && ! %POST ) {
+        my $r = DW::Request->get;
+        my $size = $r->header_in( "Content-Length" );
+        return $error->( $ML{'.error.upload.noheader'} ) unless $size;
+
+        my $errparse;
+        BML::parse_multipart( \%POST, \$errparse );
+        return $error->( $errparse ) if $errparse;
+
+        # now %POST is set, we can continue
+    }
+
+    # mode check
+    my $mode = lc( $GET{mode} || $POST{mode} );
+    my $linkback = "<p style='clear: both'><a href='$page'>"
+                 . "$ML{'.linktext.home'}</a></p>\n";
+
+    if ( LJ::did_post() && $mode ) {
+        return $error->( $ML{'error.invalidform'} )
+            unless LJ::check_form_auth();
+        $mode = '' unless $loose_refer->();
+
+        if ( $mode eq 'create' ) {
+            return $error->( $ML{'.error.create.noname'} )
+                unless $POST{name};
+            return $error->( $ML{'.error.create.nodesc'} )
+                unless $POST{desc};
+
+            if ( $POST{creator} && $siteadmin ) {
+                my $u = LJ::load_user_or_identity( $POST{creator} );
+                return $error->( BML::ml( '.error.create.badusername',
+                                          { name => $POST{creator} } ) )
+                    unless $u && $u->is_individual;
+                $POST{creator} = $u->id;
+            } else {
+                delete $POST{creator};  # siteadmin only
+            }
+            
+            return $error->( BML::ml( '.error.denied',
+                                      { action => $mode } ) )
+                unless $remote->has_priv( 'vgifts' ) || $siteadmin;
+
+            my ( $img_small, $img_large );
+
+            $img_small = $loadpic->( 'img_small', \$errmsg )
+                or return $error->( $errmsg )
+                    if $imgposted->( 'img_small' );
+
+            $img_large = $loadpic->( 'img_large', \$errmsg )
+                or return $error->( $errmsg )
+                    if $imgposted->( 'img_large' );
+
+            my $vgift = DW::VirtualGift->create( error => \$errmsg,
+                name => $POST{name}, description => $POST{desc},
+                img_small => $img_small, img_large => $img_large,
+                creatorid => $POST{creator} );
+            return $error->( $errmsg ) unless $vgift;
+
+            # hallelujah, the vgift was created.
+            $body = BML::redirect( "$page?mode=view&title=created&id=" . $vgift->id );
+
+        } elsif ( $mode eq 'edit' ) {
+            my $vgift = $checkid->( $POST{id} ) or return;
+
+            return $error->( BML::ml( '.error.denied',
+                                      { action => $mode } ) )
+                unless $vgift->can_be_edited_by( $remote );
+
+            my ( $img_small, $img_large );
+
+            $img_small = $loadpic->( 'img_small', \$errmsg )
+                or return $error->( $errmsg )
+                    if $imgposted->( 'img_small' );
+
+            $img_large = $loadpic->( 'img_large', \$errmsg )
+                or return $error->( $errmsg )
+                    if $imgposted->( 'img_large' );
+
+            # Don't honor null attributes.
+            delete $POST{name} unless length $POST{name};
+            delete $POST{desc} unless length $POST{desc};
+
+            # Note: this resets any existing approval status.
+            my $ok = $vgift->edit( error => \$errmsg, approved => '',
+                name => $POST{name}, description => $POST{desc},
+                img_small => $img_small, img_large => $img_large );
+            return $error->( $errmsg ) unless $ok;
+
+            $body = BML::redirect( "$page?mode=view&title=edited&id=" . $vgift->id );
+
+        } elsif ( $mode eq 'approve' ) {
+            my $vgift = $checkid->( $POST{id} ) or return;
+            my $id = $vgift->id;
+
+            return $error->( BML::ml( '.error.denied',
+                                      { action => $mode } ) )
+                unless $vgift->can_be_approved_by( $remote );
+
+            return $error->( $ML{'.error.yn'} )
+                if exists $POST{"${id}_approve"} && ! $POST{"${id}_approve"};
+
+            return $error->( $ML{'.error.changed'} )
+                if $POST{"${id}_chksum"} ne $vgift->checksum;
+
+            if ( $POST{"${id}_approve"} ) {
+                my $ok = $vgift->edit( error => \$errmsg,
+                    approved => $POST{"${id}_approve"},
+                    approved_why => $POST{"${id}_comment"},
+                    approved_by => $remote->userid );
+                return $error->( $errmsg ) unless $ok;
+
+                $vgift->notify_approved;
+            }
+
+            if ( $POST{"${id}_featured"} || $POST{"${id}_cost"} ) {
+                my %opts;
+                $opts{featured} = $POST{"${id}_featured"} if $POST{"${id}_featured"};
+                $opts{cost} = $POST{"${id}_cost"} if $POST{"${id}_cost"};
+                my $ok = $vgift->edit( error => \$errmsg, %opts );
+                return $error->( $errmsg ) unless $ok;
+            }
+
+            if ( $POST{"${id}_tags"} ) {
+                my $ok = $vgift->tags( $POST{"${id}_tags"},
+                    error => \$errmsg, autovivify => $siteadmin );
+                return $error->( $errmsg ) unless $ok;
+            }
+
+            return $body = BML::redirect( "${page}inactive" )
+                if $POST{activation};
+
+            # return to review page for item
+            my $link = "$page?mode=review&id=$id&title=approved";
+            my $days = $POST{days} ? $POST{days} + 0 : 0;
+            $body = $days ? BML::redirect( "$link&days=$days" )
+                          : BML::redirect( $link );
+
+        } elsif ( $mode eq 'confirm' ) {
+            my $vgift = $checkid->( $POST{id} ) or return;
+            my $mode = $remote->userid == $vgift->creatorid ? 'view' : 'review';
+
+            my $ok = $vgift->delete( $remote );
+            return $error->( $ML{'.error.delete'} ) unless $ok;
+
+            $body = BML::redirect( "$page?mode=$mode&title=deleted" );
+
+        } else {
+            # if we get here, check_referer failed or something weird happened
+            $body = BML::redirect( $page );
+        }
+
+        return;
+    }
+
+    # non post processing stuff (check for gets)
+    my $auth = LJ::form_auth();
+
+    if ( $mode eq 'view' ) {
+        if ( $GET{id} ) {
+            # view / edit form (priv check)
+            my $vgift = DW::VirtualGift->new( $GET{id} )
+                or return $error->( $ML{'.error.badid'} );
+            my $name = $vgift->name_ehtml
+                or return $error->( $ML{'.error.badid'} );
+            $title = ( $GET{title} && $strict_refer->() )
+                     ? $ML{".title.$GET{title}"}
+                     : "$title: $name";
+            if ( $vgift->can_be_edited_by( $remote ) ) {
+                # form for editing
+                $body .= "<h2>$name (#" . $vgift->id . ")</h2>\n<h3>";
+                $body .= BML::ml( 'vgift.display.createdby',
+                    { user => $vgift->creator->ljuser_display,
+                      ago => $vgift->created_ago_text } ) . "</h3>\n";
+                $body .= $postform->( 'edit', $vgift->id ) . $auth;
+                $body .= $textform->( 'name', '.label.edit.name',
+                                      { size => 40, maxlength => 80 } );
+                $body .= "<p><b>$ML{'.label.create.desc'}</b> ";
+                $body .= $vgift->description_ehtml . "</p>";
+                $body .= $textform->( 'desc', '.label.edit.desc',
+                                      { size => 40, maxlength => 255 } );
+                $body .= "<h3>$ML{'.header.imgsmall'}</h3>\n";
+                $body .= $vgift->img_small_html;
+                $body .= "<br /><label for='img_small'>$ML{'.label.edit.imgsmall'}</label>";
+                $body .= "<br />" . $imgform->( 'img_small' );
+                $body .= "<h3>$ML{'.header.imglarge'}</h3>\n";
+                $body .= $vgift->img_large_html;
+                $body .= "<br /><label for='img_large'>$ML{'.label.edit.imglarge'}</label>";
+                $body .= "<br />" . $imgform->( 'img_large' );
+                $body .= $endform->( $ML{'.submit.edit'} );
+            } else {
+                # view only
+                $body .= $begin_reviewdiv_left->();
+                $body .= $vgift->display_basic;
+                $body .= $vgift->img_large_html;
+                $body .= $begin_reviewdiv_right->();
+                $body .= $review_display->( $vgift );
+                $body .= $end_reviewdiv->();
+            }
+        } elsif ( $GET{user} ) {
+            $userview->( LJ::load_user( $GET{user} ), $siteadmin );
+            $body = BML::redirect( $page ) and return unless $body;
+        } else {  # view summary for logged in user
+            if ( $GET{title} eq 'deleted' && $strict_refer->() ) {
+                $body .= "<?warningbar " . $ML{'.review.deleted'};
+                $body .= " warningbar?>\n";
+            }
+            $userview->( $remote );
+        }
+        $body .= $linkback;
+
+    } elsif ( $mode eq 'review' ) {
+    # review queue for siteadmins
+        $title .= ": " . $ML{'.title.review'};
+        $body = BML::redirect( $page ) and return unless $siteadmin;
+
+        my $feedback = "";
+        if ( $GET{title} eq 'deleted' && $strict_refer->() ) {
+            $feedback .= "<?warningbar " . $ML{'.review.deleted'};
+            $feedback .= " warningbar?>\n";
+        }
+        if ( $GET{id} && $GET{title} eq 'approved' && $strict_refer->() ) {
+            my $vg = DW::VirtualGift->new( $GET{id} )
+                or return $error->( $ML{'.error.badid'} );
+            my $name = $vg->name_ehtml
+                or return $error->( $ML{'.error.badid'} );
+            $feedback .= "<?warningbar " . $ML{'.review.approved'};
+            $feedback .= " $name (#$GET{id}) warningbar?>\n";
+        }
+
+        my $inactive = $GET{title} && $GET{title} eq 'inactive' ? 1 : 0;
+        my $days = $GET{days} ? $GET{days} + 0 : 0;
+        my @vgifts;
+
+        if ( $GET{id} ) {
+            @vgifts = ( DW::VirtualGift->new( $GET{id} ) );
+
+            if ( $inactive ) {
+                $linkback = "<p style='clear: both'>"
+                          . "<a href='inactive'>"
+                          . "$ML{'.linktext.inactive'}</a></p>\n";
+            } else {
+            # link back to queue instead of main page
+                $linkback = $days
+                          ? "<p style='clear: both'>"
+                          . "<a href='?mode=review&days=$days'>"
+                          . "$ML{'.linktext.review.recent'}</a></p>\n"
+                          : "<p style='clear: both'>"
+                          . "<a href='?mode=review'>"
+                          . "$ML{'.linktext.review.all'}</a></p>\n";
+            }
+        } else {
+            @vgifts = $days
+                ? DW::VirtualGift->list_recent( $days )
+                : DW::VirtualGift->list_queued();
+        }
+        foreach my $vg ( @vgifts ) {
+            next unless $vg && $vg->can_be_approved_by( $remote );
+            $body .= $begin_reviewdiv_left->();
+            $body .= $vg->display_basic;
+            $body .= "<p>" . $vg->display_vieweditlinks . "</p>\n";
+            $body .= $begin_reviewdiv_right->();
+            $body .= "<h2>$ML{'.header.review'}</h2>";
+            # status display for approved and rejected gifts
+            $body .= $review_status->( $vg ) unless $vg->is_queued;
+
+            # display approval form
+            unless ( $vg->is_rejected ) {
+                my $id = $vg->id;
+                $body .= $postform->( 'approve', $id ) . $auth;
+                $body .= LJ::html_hidden( days => $days ) if $days;
+                $body .= LJ::html_hidden( "${id}_chksum" => $vg->checksum );
+
+                if ( $vg->is_queued ) {
+                    $body .= "<label for='${id}_approve'>";
+                    $body .= $ML{'.label.review.approval'} . '</label>';
+                    $body .= LJ::html_select( { name => "${id}_approve", selected => '' },
+                                              '' => '',
+                                              'Y' => $ML{'.label.review.answer.y'},
+                                              'N' => $ML{'.label.review.answer.n'},
+                                            );
+                    $body .= '<br />';
+                    $body .= $textform->( "${id}_comment", '.label.review.comment',
+                                          { textarea => 1, cols => 40, rows => 10, br => 1 } );
+                } elsif ( $vg->is_approved ) {
+                    # prompt for suggestions
+                    $body .= "<h3>" . $ML{'.label.review.optional'} . "</h3>";
+                    $body .= LJ::html_hidden( activation => $inactive );
+                    $body .= $shopform->( $vg );
+                }
+                $body .= $endform->( $ML{'.submit.review'} );
+            }
+            $body .= $end_reviewdiv->();
+        }
+        $body ||= $ML{'.review.empty'};
+        $body = $feedback . $body . $linkback;
+
+    } elsif ( $mode eq 'delete' ) {
+        $title = $ML{'.title.delete'};
+        $body = BML::redirect( $page ) and return unless $GET{id};
+        my $vgift = $checkid->( $GET{id} ) or return;
+        if ( $vgift->can_be_deleted_by( $remote ) ) {
+            $body .= '<h3>' . $ML{'.header.delete'} . '</h3>';
+            $body .= $vgift->display_basic;
+            $body .= $postform->( 'confirm', $vgift->id ) . $auth;
+            $body .= $endform->( $ML{'.submit.delete'} );
+        } else {  # can't be deleted
+            $body .= "<p>$ML{'.error.delete'}</p>";
+        }
+        $body .= $linkback;
+
+    } elsif ( $mode eq 'artists' ) {
+        $title .= ": $ML{'.title.artists'}";
+        $body .= '<ol>';
+        $body .= DW::VirtualGift->display_creatorlist;
+        $body .= "</ol>$linkback";
+
+    } else {
+        # DEFAULT PAGE DISPLAY
+        $body .= "<div class='vgdiv' id='right' style='text-align: center'>";
+        $body .= "<h2>$ML{'.header.userqueue'}</h2>";
+        my @vgifts = DW::VirtualGift->list_created_by( $remote );
+        my $queue = "";
+        foreach my $vg ( @vgifts ) {
+            next unless $vg && $vg->can_be_edited_by( $remote );
+            $queue .= '<li>"' . $vg->name_ehtml . '"';
+            $queue .= ' (' . $vg->created_ago_text . ') ';
+            $queue .= $vg->display_vieweditlinks . "</li>\n";
+        }
+        $queue .= "<li style='padding-top: 0.5em'><a href='?mode=view'>$ML{'.linktext.viewall'}</a></li>\n"
+            if $queue;
+
+        $body .= $queue ? "\n<ul>$queue</ul>" : "<i>$ML{'.queue.empty'}</i>";
+
+        $body .= "<h2>$ML{'.header.artists'}</h2>";
+        # reuse $queue for building string
+        $queue = DW::VirtualGift->display_creatorlist( 5 );
+        $queue .= "<li style='padding-top: 0.5em'><a href='?mode=artists'>$ML{'.linktext.viewall'}</a></li>\n"
+            if $queue;
+
+        $body .= $queue ? "\n<ul>$queue</ul>" : "<i>$ML{'.queue.empty'}</i>";
+
+        if ( $siteadmin ) {
+            $body .= qq|
+                <h2>$ML{'.header.siteadmin'}</h2>
+                <ul>
+                <li><a href='?mode=review&days=14'>
+                $ML{'.linktext.review.recent'}</a></li>
+                <li><a href='?mode=review'>
+                $ML{'.linktext.review.all'}</a></li>
+                <li><a href='tags'>$ML{'.linktext.tags'}</a></li>
+                <li><a href='inactive'>$ML{'.linktext.inactive'}</a></li>
+                </ul>
+                |;
+        }
+
+        $body .= "</div><div class='vgdiv' id='left'>";
+        $body .= "<h2>$ML{'.header.create'}</h2>";
+        $body .= $postform->( 'create' ) . $auth;
+        $body .= $textform->( 'name', '.label.create.name',
+                              { size => 40, maxlength => 80, br => 1 } );
+        $body .= '<br />';
+        $body .= $textform->( 'desc', '.label.create.desc',
+                              { size => 40, maxlength => 255, br => 1 } );
+        if ( $siteadmin ) {
+            $body .= '<br />';
+            $body .= $textform->( 'creator', '.label.create.creator',
+                                  { size => 40, maxlength => 80, br => 1 } );
+        }
+
+        $body .= "<h3>$ML{'.header.imgsmall'}</h3>\n";
+        $body .= $imgform->( 'img_small' );
+        $body .= "<h3>$ML{'.header.imglarge'}</h3>\n";
+        $body .= $imgform->( 'img_large' );
+        $body .= "<p>$ML{'.note.svg'}</p>";
+        $body .= $endform->( $ML{'.submit.create'} );
+        $body .= "</div>\n";
+    }
+
+    return;
+}
+_code?><?page
+title=><?_code return $title; _code?>
+body=><?_code return $body; _code?>
+head<=
+<style type="text/css">
+label       { margin-right: 1em; }
+.vgdiv      { margin-bottom: 15px; width: 45%; }
+#content p  { margin-top: 1em; }
+#content h2 { margin-top: 1em; }
+#content h3 { margin: 0.5em 0; }
+#content li { margin: 0.25em 0; }
+#content ol { list-style: decimal inside; }
+#left       { float: left; }
+#right      { float: right; }
+</style>
+<=head
+page?>
diff -r 4c7678ebddbf -r c4063e1602c6 htdocs/admin/vgifts/index.bml.text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/admin/vgifts/index.bml.text	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,133 @@
+.error=Error
+
+.error.badid=An error occurred while trying to load this vgift.
+
+.error.changed=The information for this virtual gift has changed. Please reload and try again.
+
+.error.create.badusername=The username you specified ([[name]]) does not appear to be a valid personal or identity account.
+
+.error.create.nodesc=Please give your new gift a description.
+
+.error.create.noname=Please give your new gift a name.
+
+.error.delete=This gift cannot be deleted, either because you don't have the required permissions or because it has already been made available for sale.
+
+.error.denied=You do not have permission to [[action]] this gift.
+
+.error.upload.badtype=Files of type [[filetype]] are not supported.  You can only upload GIF, PNG or JPG files.
+
+.error.upload.badurl=The address for the picture to be uploaded does not look correct. It should start with <b>http://</b>
+
+.error.upload.dimstoolarge=The dimensions of your image ([[imagesize]]) exceed maximum size.  Your picture can't be larger than [[maxsize]] pixels.
+
+.error.upload.filetoolarge=Image uploaded is too large.  File size cannot exceed [[maxsize]] KB.
+
+.error.upload.nofile=You must choose a file to upload
+
+.error.upload.noheader=No content-length header: can't upload
+
+.error.upload.nomogilefs=Couldn't load MogileFS: can't upload
+
+.error.upload.nourl=You must enter the URL of an image
+
+.error.upload.urlerror=An error ocurred while trying to fetch your image.
+
+.error.yn=Please select Yes or No.
+
+.header.artists=Leaderboard
+
+.header.create=Create a new virtual gift
+
+.header.delete=Are you sure?
+
+.header.imglarge=Large image
+
+.header.imgsmall=Small (main) image
+
+.header.review=Review:
+
+.header.siteadmin=Site Admins:
+
+.header.userqueue=Your other pending submissions:
+
+.label.create.creator=Username to credit as creator <u>(admin only)</u>:
+
+.label.create.desc=Description (for image alt text):
+
+.label.create.name=Name (must be unique):
+
+.label.edit.desc=New description?
+
+.label.edit.imglarge=Choose different large image:
+
+.label.edit.imgsmall=Choose different small image:
+
+.label.edit.name=New name?
+
+.label.fromfile=From <u>F</u>ile:
+
+.label.fromfile.key|notes=Enter here a <b>lower-case</b> version of the letter you underlined in ".fromfile". This is the shortcut key for the fromfile option.
+.label.fromfile.key=f
+
+.label.fromurl=From U<u>R</u>L:
+
+.label.fromurl.key|notes=Enter here a <b>lower-case</b> version of the letter you underlined in ".fromurl". This is the shortcut key for the fromurl option.
+.label.fromurl.key=r
+
+.label.review.answer.n=No
+
+.label.review.answer.y=Yes
+
+.label.review.approval=Approve?
+
+.label.review.approved=<span style="color: green">Approved by:</span>
+
+.label.review.comment=Comment:
+
+.label.review.optional=Optional: suggestions for shop qualifiers
+
+.label.review.rejected=<span style="color: red">Rejected by:</span>
+
+.label.review.why=<b>Comment: </b>
+
+.linktext.home=Back to Virtual Gifts main page
+
+.linktext.inactive=Manage Inactive Gifts
+
+.linktext.review.all=View All Pending Submissions
+
+.linktext.review.recent=View Recent Submissions
+
+.linktext.tags=Manage Tags
+
+.linktext.viewall=View All
+
+.note.svg=<i><b>Note:</b> If you have a scalable vector graphic, you will need to submit it via another method.</i>
+
+.queue.empty=(none available)
+
+.review.approved=Approval changes submitted:
+
+.review.deleted=The gift was deleted.
+
+.review.empty=There are no relevant gifts available to display.
+
+.submit.create=Create
+
+.submit.edit=Submit Changes
+
+.submit.delete=Yes, I want to delete this gift.
+
+.submit.review=Submit Review
+
+.title=Virtual Gifts
+
+.title.artists=Artists
+
+.title.created=Creation Successful!
+
+.title.delete=Delete A Gift
+
+.title.edited=Edit Successful!
+
+.title.review=Review Queue
diff -r 4c7678ebddbf -r c4063e1602c6 htdocs/admin/vgifts/tags.bml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/admin/vgifts/tags.bml	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,308 @@
+<?_code
+{
+    use strict;
+    use vars qw($title $body $page $action %GET %POST);
+
+    use DW::VirtualGift;
+
+    $title = $ML{'.title'};
+    $body = "";
+    $page = "$LJ::SITEROOT/admin/vgifts/tags";
+    $action = 'tags';  # for $postform
+
+    # helper routines
+
+    my $error = sub { 
+        $title = $ML{'.error'};
+        $body = join '', @_;
+        return undef;
+    };
+    my $errmsg;
+
+    my $loose_refer = sub {
+        return 1 unless my $refer = BML::get_client_header('Referer');
+        my ( $getargs ) = ( $refer =~ m/\?(.*)$/ );
+        return LJ::check_referer( $page ) unless $getargs;
+        return LJ::check_referer( "$page?$getargs" );
+    };
+
+    my $strict_refer = sub {
+        # make sure we have a referer header. check_referer doesn't care.
+        return BML::get_client_header('Referer') && $loose_refer->();
+    };
+
+    my $textform = sub {
+        my ( $name, $ml, $optref ) = @_;
+        my %opts = $optref ? %$optref : ();
+        my $text = "\n<label for='$name'>$ML{$ml}</label>";
+        $text .= '<br />' and delete $opts{br} if $opts{br}; 
+        $text .= LJ::html_text( { name => $name, id => $name, %opts } );
+        return $text;
+    };
+
+    my $checkid = sub {
+        my $id = shift;
+        return $error->( $ML{'error.invalidform'} ) unless $id;
+
+        my $vgift = DW::VirtualGift->new( $id );
+        return $error->( $ML{'.error.badid'} )
+            unless $vgift && $vgift->name;
+
+        return $vgift;
+    };
+
+    my $postform = sub {
+        my ( $mode, $id ) = @_;
+        my $text = "<form action='$action' method='post'>";
+        $text .= LJ::html_hidden( mode => $mode ) if $mode;
+        $text .= LJ::html_hidden( id => $id ) if $id && $id =~ /^\d+$/;
+        return $text;
+    };
+
+    my $endform = sub {
+        my ( $submit ) = @_;
+        my $text = '<p>';
+        $text .= LJ::html_submit( submit => $submit ) ;
+        $text .= '</p></form>';
+        return $text;
+    };
+
+    # end helper routines
+
+    # login check
+    my $remote = LJ::get_remote();
+    return $error->( "<?needlogin?>" ) unless $remote;
+
+    # priv check
+    my @displayprivs = ( "siteadmin:vgifts" );
+    my $siteadmin = $remote->has_priv( 'siteadmin', 'vgifts' ) || $LJ::IS_DEV_SERVER;
+
+    return $error->( BML::ml( "admin.noprivserror",
+                     { numprivs => scalar @displayprivs,
+                       needprivs => "<b>" . join(", ", @displayprivs) . "</b>"
+                     } ) )
+        unless $siteadmin;
+
+    # mode check
+    my $mode = lc( $GET{mode} || $POST{mode} );
+    my $linkback = "<p style='clear: both'><a href='$page'>"
+                 . BML::ml( '.linktext.back', { title => $ML{'.title'} } )
+                 . "</a></p>\n";
+    my $linkhome = "<p style='clear: both'><a href='/admin/vgifts/'>"
+                 . "$ML{'.linktext.home'}</a></p>\n";
+
+    if ( LJ::did_post() && $mode ) {
+        return $error->( $ML{'error.invalidform'} )
+            unless LJ::check_form_auth();
+        $mode = '' unless $loose_refer->();
+
+        if ( $mode eq 'edit' ) {
+            my $tagid = $POST{id}
+                or return $error->( $ML{'.error.badid'} );
+            my $tag = DW::VirtualGift->get_tagname( $tagid )
+                or return $error->( $ML{'.error.badid'} );
+
+            return $error->( $ML{'.error.needpriv'} )
+                if $POST{"${tagid}_privarg"} && ! $POST{"${tagid}_addpriv"};
+
+            # make sure the new priv is valid
+            my $priv = $POST{"${tagid}_addpriv"};
+            my $addpriv_id;
+            if ( $priv ) {
+                $addpriv_id = DW::VirtualGift->validate_priv( $priv );
+                return $error->( LJ::Lang::ml( '.error.badpriv',
+                    { priv => LJ::ehtml( $priv ) } ) )
+                        unless $addpriv_id;
+            }
+
+            # also validate arg if given
+            my $arg = $POST{"${tagid}_privarg"};
+            if ( $arg && $arg ne '*' ) {
+                my $valid_args = LJ::list_valid_args( $priv );
+                return $error->( LJ::Lang::ml( '.error.badarg',
+                    { priv => LJ::ehtml( $priv ),
+                      arg  => LJ::ehtml( $arg ) } ) )
+                        unless $valid_args && $valid_args->{$arg};
+            }
+
+            # process rename first
+            if ( my $newtag = $POST{"${tagid}_rename"} ) {
+                DW::VirtualGift->alter_tag( $tag, $newtag )
+                    or return $error->( LJ::Lang::ml( '.error.badtagname',
+                        { tag => LJ::ehtml( $newtag ) } ) );
+                $tag = $newtag;  # subsequent changes target $newtag
+            }
+
+            # process new privilege
+            if ( $addpriv_id ) {
+                DW::VirtualGift->add_priv_to_tag( $tag, $priv, $arg )
+                    or return $error->( LJ::Lang::ml( '.error.privarg',
+                        { privarg => LJ::ehtml( "$priv:$arg" ) } ) );
+            }
+
+            # process old privileges
+            if ( $POST{"${tagid}_maxprivnum"} ) {
+                # only remove existing privs if not renamed (can resubmit)
+                unless ( $POST{"${tagid}_rename"} ) {
+                    DW::VirtualGift->remove_priv_from_tag( $tag, $_->[0], $_->[1] )
+                        foreach DW::VirtualGift->list_tagprivs( $tag );
+                }
+                # add back selected privs
+                foreach my $i ( 0..$POST{"${tagid}_maxprivnum"} ) {
+                    if ( $POST{"${tagid}_priv$i"} ) {
+                        my ( $priv, $arg ) =
+                            ( $POST{"${tagid}_priv$i"} =~ /^([^:]+):(.*)$/ );
+                        next unless $priv;
+                        DW::VirtualGift->add_priv_to_tag( $tag, $priv, $arg )
+                            or return $error->( BML::ml( '.error.privarg',
+                                { privarg => LJ::ehtml( "$priv:$arg" ) } ) );
+                    }
+                }
+            }
+
+            $tagid = DW::VirtualGift->get_tagid( $tag )
+                if $POST{"${tagid}_rename"};
+            $body = BML::redirect( "$page?mode=view&title=edited&id=$tagid" );
+
+        } elsif ( $mode eq 'confirm' ) {
+            my $tag = DW::VirtualGift->get_tagname( $POST{id} )
+                or return $error->( $ML{'.error.badid'} );
+            DW::VirtualGift->alter_tag( $tag );
+            $body = BML::redirect( $page );
+
+        } else {
+            # if we get here, check_referer failed or something weird happened
+            $body = BML::redirect( $page );
+        }
+
+        return;
+    }
+
+    # non post processing stuff (check for gets)
+    my $auth = LJ::form_auth();
+    my $tag = LJ::durl( $GET{tag} || '' );
+    my $id;
+
+    if ( $mode ) {
+        $body = BML::redirect( $page ) and return unless $tag;
+        $id = DW::VirtualGift->get_tagid( $tag )
+            or return $error->( $ML{'.error.badid'} );
+    }
+
+    if ( $mode eq 'view' ) {
+        my $etag = LJ::ehtml( $tag );
+        my $urltag = LJ::eurl( $tag );
+        $title = ( $GET{title} && $strict_refer->() )
+                 ? $ML{".title.$GET{title}"}
+                 : "$title: $etag";
+        $body .= "<h2>$ML{'.label.edit.tagname'}: <b>$etag</b> &nbsp; ";
+        $body .= "<a href='?mode=delete&tag=$urltag'>";
+        $body .= "$ML{'.linktext.deletetag'}</a></h2>\n";
+        $body .= $postform->( 'edit', $id ) . $auth;
+        $body .= '<p>';
+        $body .= $textform->( "${id}_rename", '.label.edit.name',
+                              { size => 25, maxlength => 40 } );
+        $body .= "</p><p><i>$ML{'.note.tagmerge'}</i></p><p>";
+        $body .= $textform->( "${id}_addpriv", '.label.edit.priv',
+                              { size => 15, maxlength => 25 } );
+        $body .= ' : ';
+        $body .= LJ::html_text( { name => "${id}_privarg",
+                                  id => "${id}_privarg",
+                                  size => 15, maxlength => 25 } );
+        $body .= '</p>';
+
+        $body .= "<h3>$ML{'.header.privlist'}</h3>\n";
+        my @privarg = DW::VirtualGift->list_tagprivs( $tag );
+        my $list = "";
+        foreach my $i ( 0..$#privarg ) {
+            my ( $priv, $arg ) = @{ $privarg[$i] };
+            $list .= '<li>';
+            $list .= LJ::html_check( { value => "$priv:$arg",
+                                       selected => 1,
+                                       name => "${id}_priv$i",
+                                       id => "${id}_priv$i" } );
+            $list .= $arg? " $priv:$arg" : " $priv";
+            $list .= '</li>';
+        }
+        if ( $list ) {
+            $body .= "<ul>$list</ul><i>$ML{'.note.removeprivs'}</i>";
+            $body .= LJ::html_hidden( "${id}_maxprivnum" => $#privarg );
+        } else {
+            $body .= $ML{'.queue.empty'};
+        }
+        $body .= $endform->( $ML{'.submit.edit'} );
+
+        $body .= "<h3>$ML{'.header.giftlist'}</h3>\n";
+        my @vgifts = DW::VirtualGift->list_tagged_with( $tag );
+        $list = "";
+        foreach my $vg ( @vgifts ) {
+            $list .= '<li>' . $vg->name_ehtml . ' (#' . $vg->id . ') ';
+            $list .= $vg->is_approved ? '' 
+                                      : "<b>$ML{'.note.notapproved'}</b> ";
+            $list .= $vg->is_inactive ? '' 
+                                      : "<b>$ML{'.note.active'}</b> ";
+            $list .= $vg->display_vieweditlinks( $vg->is_queued );
+            $list .= " | <a href='?mode=remove&tag=$urltag&vg=";
+            $list .= $vg->id . "'>$ML{'.linktext.removetag'}</a></li>";
+        }
+        $body .= $list ? "<ul>$list</ul>" : $ML{'.review.empty'};
+        $body .= $linkback;
+
+    } elsif ( $mode eq 'delete' ) {
+        $title .= ": $ML{'.title.delete'}";
+        my $etag = LJ::ehtml( $tag );
+        $body .= '<h2>' . BML::ml( '.header.delete', { tag => $etag } ) . '</h2>';
+        $body .= $postform->( 'confirm', $id ) . $auth;
+        $body .= $endform->( $ML{'.submit.delete'} );
+        $body .= $linkback;
+
+    } elsif ( $mode eq 'remove' ) {
+        my $vg = $checkid->( $GET{vg} ) or return;
+        # quickly process and return
+        $vg->remove_tag_by_id( $id );
+        $body = BML::redirect( "$page?mode=view&tag=" . LJ::eurl( $tag ) );
+    } else {
+        # DEFAULT PAGE DISPLAY
+        my $counts = DW::VirtualGift->fetch_tagcounts_approved;
+        my %nonpriv = map { $_ => $counts->{$_} }
+            DW::VirtualGift->list_nonpriv_tags;
+        delete @$counts{ keys %nonpriv };  # leaving only privileged data
+
+        my $printtags = sub {
+            my $hr = shift;
+            my $queue = "";
+            foreach my $tag ( sort keys %$hr ) {
+                my $urltag = LJ::eurl( $tag );
+                my $etag = LJ::ehtml( $tag );
+                $queue .= "<h3>$etag (" . $hr->{$tag} . ") &nbsp; ";
+                $queue .= "<a href='?mode=view&tag=$urltag'>";
+                $queue .= "$ML{'vgift.display.linktext.viewedit'}</a> | ";
+                $queue .= "<a href='?mode=delete&tag=$urltag'>";
+                $queue .= "$ML{'.linktext.deletetag'}</a></h3>\n";
+            }
+            return $queue ? $queue : "<h3>$ML{'.queue.empty'}</h3>";
+        };
+
+        $body .= "<h2>$ML{'.header.priv'}</h2>\n";
+        $body .= $printtags->( $counts );
+        $body .= "<h2>$ML{'.header.nonpriv'}</h2>\n";
+        $body .= $printtags->( \%nonpriv );
+        $body .= $linkhome;
+    }
+
+    return;
+}
+_code?><?page
+title=><?_code return $title; _code?>
+body=><?_code return $body; _code?>
+head<=
+<style type="text/css">
+label       { margin-right: 1em; }
+#content p  { margin-top: 1em; }
+#content h2 { margin-top: 1em; }
+#content h3 { margin: 0.5em 0; }
+#content li { margin: 0.25em 0; }
+#content ol { list-style: decimal inside; }
+</style>
+<=head
+page?>
diff -r 4c7678ebddbf -r c4063e1602c6 htdocs/admin/vgifts/tags.bml.text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/admin/vgifts/tags.bml.text	Thu Oct 28 12:23:03 2010 +0800
@@ -0,0 +1,61 @@
+.error=Error
+
+.error.badarg=The argument [[arg]] is not valid for the privilege [[priv]].
+
+.error.badid=The specified tag was not found.
+
+.error.badpriv=There is no defined privilege named [[priv]].
+
+.error.badtagname=Could not rename to [[tag]].
+
+.error.needpriv=You specified an arg but failed to specify an associated privilege.
+
+.error.privarg=There was a problem adding [[privarg]] permissions to the specified tag.
+
+.error.upload.noheader=No content-length header: can't upload
+
+.header.delete=Are you sure you want to delete the tag '[[tag]]' from all gifts?
+
+.header.giftlist=Current gifts with this tag:
+
+.header.nonpriv=Nonprivileged (Public) Tags
+
+.header.priv=Privileged (Restricted) Tags
+
+.header.privlist=Current restrictions:
+
+.label.edit.name=New name:
+
+.label.edit.priv=Restrict to users with the following privilege:
+
+.label.edit.tagname=Tag Name
+
+.linktext.back=Back to [[title]]
+
+.linktext.deletetag=[Delete This Tag]
+
+.linktext.home=Back to Virtual Gifts main page
+
+.linktext.removetag=[Remove This Tag]
+
+.note.active=(Active)
+
+.note.notapproved=(Not Approved)
+
+.note.removeprivs=Uncheck boxes next to any restrictions you want removed.
+
+.note.tagmerge=Existing tag names are allowed; privileges will be merged.
+
+.queue.empty=(none available)
+
+.review.empty=There are no relevant gifts available to display.
+
+.submit.edit=Submit Changes
+
+.submit.delete=Yes, I want to delete this tag.
+
+.title=Virtual Gifts: Tag Management
+
+.title.delete=Delete A Tag
+
+.title.edited=Edit Successful!
diff -r 4c7678ebddbf -r c4063e1602c6 views/admin/index.tt.text
--- a/views/admin/index.tt.text	Thu Oct 28 11:15:07 2010 +0800
+++ b/views/admin/index.tt.text	Thu Oct 28 12:23:03 2010 +0800
@@ -83,6 +83,9 @@
 .admin.userlog.link=Userlog Viewer
 .admin.userlog.text=Shows you a user's logged actions.
 
+.admin.vgifts.link=Virtual Gifts
+.admin.vgifts.text=Maintain the virtual gift shop and submit new gift ideas.
+
 .anysupportpriv=any support priv
 .devserver=dev server
 
--------------------------------------------------------------------------------
kareila: (Default)

[personal profile] kareila 2010-10-28 05:10 am (UTC)(link)
EEEEEEEEEEEEEEEE YAYAYAYAYAYAYAYAY FINALLY!!!!
markmanching: (Default)

[personal profile] markmanching 2010-10-28 06:46 am (UTC)(link)
ads found this code (IDK)


web.ads.advertisement=<a [[aopts]]>Advertisement</a>
web.ads.advertisement_nolink=Advertisement


need to cleanup
kareila: (Default)

[personal profile] kareila 2010-10-28 06:58 am (UTC)(link)
Yeah, I think all the web.ads strings in en.dat can go. But that's a very low priority cleanup.
markmanching: (Default)

[personal profile] markmanching 2010-10-28 08:08 am (UTC)(link)
thanks! investigate the code by code XD
yvi: Kaylee half-smiling, looking very pretty (Default)

[personal profile] yvi 2010-10-28 04:45 pm (UTC)(link)
YAY! At last!