diff --git l10n_community/ajax.inc l10n_community/ajax.inc
deleted file mode 100644
index 779ea10..0000000
--- l10n_community/ajax.inc
+++ /dev/null
@@ -1,166 +0,0 @@
-<?php
-// $Id: ajax.inc,v 1.1.2.18.2.6 2009-12-05 13:44:56 goba Exp $
-
-/**
- * @file
- *   AJAX callbacks for localization community.
- */
-
-/**
- * Return a HTML list of projects, releases and counts of where strings
- * appear in the managed projects.
- *
- * We could have been provided much more information, but usability should
- * also be kept in mind. It is possible to investigate hidden information
- * sources though, like tooltips on the release titles presented.
- *
- * This callback is invoked from JavaScript and is used as an AHAH provider.
- *
- * @param $langcode
- *   Language code.
- * @param $sid
- *   Source string id.
- */
-function l10n_community_string_details($langcode = NULL, $sid = 0) {
-  // List of project releases, where this string is used.
-  $result = db_query('SELECT l.pid, p.title project_title, l.rid, r.title release_title, COUNT(l.lineno) as occurance_count FROM {l10n_community_line} l INNER JOIN {l10n_community_project} p ON l.pid = p.pid INNER JOIN {l10n_community_release} r ON l.rid = r.rid WHERE l.sid = %d AND p.status = 1 GROUP BY l.rid ORDER BY l.pid, l.rid', $sid);
-
-  $list = array();
-  $output = array();
-  $previous_project = '';
-  while ($instance = db_fetch_object($result)) {
-    if ($instance->project_title != $previous_project) {
-      if (!empty($list)) {
-        $output[] = join(', ', $list);
-      }
-      $list = array('<em>'. $instance->project_title .':</em> '. $instance->release_title .' ('. $instance->occurance_count .')');
-    }
-    else {
-      $list[] = $instance->release_title .' ('. $instance->occurance_count .')';
-    }
-    $previous_project = $instance->project_title;
-  }
-  $output[] = join(', ', $list);
-  $usage_info = '<strong>'. t('Used in:') .'</strong>'. theme('item_list', $output);
-
-  // Information about translator and translation timestamp.
-  $translation_info = '';
-  $translation = db_fetch_object(db_query("SELECT translation, uid_entered, time_entered FROM {l10n_community_translation} WHERE language = '%s' AND sid = %d AND is_active = 1 AND is_suggestion = 0", $langcode, $sid));
-  if (!empty($translation->translation)) {
-    $account = user_load(array('uid' => $translation->uid_entered));
-    $translation_info = t('<strong>Translated by:</strong><br /> %username at %date', array('%username' => $account->name, '%date' => format_date($translation->time_entered)));
-  }
-
-  print $usage_info . $translation_info; // . $suggestion_info;
-  exit;
-}
-
-function l10n_community_string_suggestions($langcode = NULL, $sid = 0) {
-  global $user;
-
-  // Existing, "unresolved" suggestions.
-  $suggestions = array();
-  $result = db_query("SELECT t.tid, t.sid, t.translation, t.uid_entered, t.time_entered, u.name FROM {l10n_community_translation} t LEFT JOIN {users} u ON u.uid = t.uid_entered WHERE t.language = '%s' AND t.sid = %d AND t.is_active = 1 AND t.is_suggestion = 1 ORDER BY t.time_entered", $langcode, $sid);
-
-  $perm = l10n_community_get_permission($langcode);
-  while ($suggestion = db_fetch_object($result)) {
-    // This detail pane is only retrieved from JS, so we are free to output obtrusive JS here.
-    $token = "'". drupal_get_token('l10n_server_'. $suggestion->tid .'_'. $suggestion->sid) ."'";
-    $approve_button = $decline_button = '';
-    if ($perm & ($suggestion->uid_entered == $user->uid ? L10N_PERM_MODERATE_OWN : L10N_PERM_MODERATE_OTHERS)) {
-      $approve_button = theme('l10n_community_button', 'approve', 'l10n-approve', 'onclick="l10nCommunity.approveSuggestion('. $suggestion->tid .','. $suggestion->sid .', this, '. $token .');" title="'. t('Approve suggestion.') .'"');
-      $decline_button = theme('l10n_community_button', 'decline', 'l10n-decline', 'onclick="l10nCommunity.declineSuggestion('. $suggestion->tid .','. $suggestion->sid .', this, '. $token .');" title="'. t('Decline suggestion.') .'"');
-    }
-
-    // Plural versions are shown in a short form.
-    // Since we no longer store an additional copy of strings in JS
-    // and the null byte is stripped out by browsers (?), we need to
-    // use a DOM-safe delimiter: ";  "
-    $translation = strpos($suggestion->translation, "\0") ? str_replace(chr(0), ";  ", $suggestion->translation) : $suggestion->translation;
-    $translation = l10n_community_format_text($translation, $suggestion->sid);
-    $suggestions[] = $translation ."<div class='l10n-approval-buttons'>". $approve_button . $decline_button .'</div><div class="l10n-suggestion-by">'. t('by %user at %date', array('%user' => (empty($suggestion->name) ? variable_get('anonymous', t('Anonymous')) : $suggestion->name), '%date' => format_date($suggestion->time_entered))) .'</div>';
-  }
-  if (count($suggestions)) {
-    // List of suggestions.
-    $suggestion_info = '<strong>'. t('Outstanding suggestions:') .'</strong>'. theme('l10n_community_strings', $suggestions);
-  }
-  else {
-    $suggestion_info .= '<strong>'. t('No suggestions found.') .'</strong>';
-  }
-  print $suggestion_info;
-  exit;
-}
-
-/**
- * Checks permission of current user to approve or decline $tid.
- *
- * @param $tid
- *   Suggestion ID.
- * @return
- *   The suggestion object if validated $tid, user permissions and token.
- */
-function l10n_community_string_ajax_suggestion($tid = 0) {
-  global $user;
-  
-  if (($suggestion = db_fetch_object(db_query("SELECT * FROM {l10n_community_translation} WHERE tid = %d AND is_suggestion = 1 AND is_active = 1", $tid))) &&
-      ($perm = l10n_community_get_permission($suggestion->language)) &&
-      ($perm & ($suggestion->uid_entered == $user->uid ? L10N_PERM_MODERATE_OWN : L10N_PERM_MODERATE_OTHERS)) &&
-      !empty($_GET['form_token']) &&
-      drupal_valid_token($_GET['form_token'], 'l10n_server_'. $suggestion->tid .'_'. $suggestion->sid)) {
-    return $suggestion;
-  }
-  return FALSE;
-}
-
-/**
- * Records approval of a previous string suggestion.
- *
- * This callback is invoked from JavaScript.
- *
- * @param $tid
- *   Suggestion ID.
- */
-function l10n_community_string_approve($tid = 0) {
-  global $user;
-
-  if ($suggestion = l10n_community_string_ajax_suggestion($tid)) {
-    // Mark existing translations and suggestions as inactive in this language.
-    db_query("UPDATE {l10n_community_translation} SET is_active = 0 WHERE sid = %d AND language = '%s'", $suggestion->sid, $suggestion->language);
-    // Remove placeholder translation record (which was there if
-    // first came suggestions, before an actual translation).
-    db_query("DELETE FROM {l10n_community_translation} WHERE sid = %d AND translation = '' AND language = '%s'", $suggestion->sid, $suggestion->language);
-    // Mark this exact suggestion as active, and set approval time.
-    db_query("UPDATE {l10n_community_translation} SET time_approved = %d, uid_approved = %d, has_suggestion = 0, is_suggestion = 0, is_active = 1 WHERE tid = %d;", time(), $user->uid, $suggestion->tid);
-    // Return something so the client sees we are OK.
-    print 'done';
-    exit;
-  }
-  print 'error';
-  exit;
-}
-
-/**
- * Records decline action of a previous string suggestion.
- *
- * This callback is invoked from JavaScript.
- *
- * @param $tid
- *   Suggestion ID.
- */
-function l10n_community_string_decline($tid = 0) {
-  if ($suggestion = l10n_community_string_ajax_suggestion($tid)) {
-    // Mark this suggestion as inactive.
-    db_query("UPDATE {l10n_community_translation} SET is_active = 0 WHERE tid = %d", $tid);
-    // Let's see if we have any other suggestion remaining in this language.
-    $count = db_result(db_query("SELECT COUNT(*) FROM {l10n_community_translation} WHERE sid = %d AND is_suggestion = 1 AND is_active = 1 AND language = '%s'", $suggestion->sid, $suggestion->language));
-    if (!$count) {
-      // No remaining suggestions in this language.
-      db_query("UPDATE {l10n_community_translation} SET has_suggestion = 0 WHERE sid = %d AND is_suggestion = 0 AND is_active = 1 AND language = '%s'", $suggestion->sid, $suggestion->language);
-    }
-    // Return something so the client sees we are OK.
-    print 'done';
-    exit;
-  }
-  print 'error';
-  exit;
-}
diff --git l10n_community/devel.inc l10n_community/devel.inc
index 364f36c..8861ba3 100644
--- l10n_community/devel.inc
+++ l10n_community/devel.inc
@@ -49,7 +49,7 @@ function l10n_server_generate_languages_form_submit($form_id, &$form_state) {
         unset($predefined[$key]);
       }
     }
-    
+
     $formulas = l10n_community_plural_formulas();
     $default_formula = $default = 'nplurals=2; plural=(n!=1);';
     foreach ($languages as $langcode => $language) {
diff --git l10n_community/editor.css l10n_community/editor.css
new file mode 100644
index 0000000..ba44987
--- /dev/null
+++ l10n_community/editor.css
@@ -0,0 +1,303 @@
+body {
+  font-family:Verdana;
+  font-size:12px;
+}
+
+
+#l10n-community-translate-form .sticky-header {
+  z-index:2;
+}
+
+.l10n-table .form-item {
+  margin:0;
+  padding:0;
+}
+
+.l10n-table td {
+  vertical-align:top;
+}
+
+.l10n-table ul { margin:0; padding:0; }
+
+.l10n-table li {
+  list-style: none;
+  background: none;
+  padding:0;
+  margin:0;
+}
+
+td.source {
+  width:45%;
+}
+  td.source .l10n-string {
+    padding:4px 6px;
+  }
+  td.source .l10n-usage {
+    padding:0 6px 6px 16px;
+    font-size:10px;
+    line-height:12px;
+  }
+  td.source .l10n-usage .l10n-more {
+    font-size:10px;
+    text-decoration:none;
+    color:#000;
+    opacity:0.4;
+  }
+  td.source .l10n-usage .l10n-more:before {
+    content:"▾ ";
+  }
+  td.source .l10n-usage .l10n-more:hover,
+  td.source .l10n-usage .l10n-more:active {
+    color:#666;
+  }
+  td.source .l10n-usage li {
+    list-style-type:disc;
+    background:none;
+    margin:0 0 0 22px;
+    padding:0;
+  }
+
+li.translation {
+  padding:4px 0;
+  padding-left:20px;
+  clear:left;
+  position:relative;
+  -webkit-transition: opacity 0.2s ease-out;
+}
+
+li.translation > .selector {
+  position:absolute;
+  left:2px;
+  top:5px;
+  margin:0;
+}
+
+
+.l10n-table ul.actions {
+  float:right;
+}
+  li.translation:not(.is-active) > .actions li.stable,
+  li.translation.is-active > .actions li.declined { display:none; }
+  .l10n-table ul.actions {
+    overflow:hidden;
+    float: right;
+    margin-right:4px;
+    -webkit-user-select:none;
+  }
+  .l10n-table ul.actions li {
+    float:right;
+    font-size:80%;
+    line-height:20px;
+    padding:0 0 4px 4px;
+  }
+  .l10n-table ul.actions li label {
+    width:16px;
+    height:16px;
+    overflow:hidden;
+    display:block;
+    text-indent:-1000px;
+    opacity:0.6;
+    cursor:pointer;
+  }
+  .l10n-table ul.actions li label:active {
+    position:relative; top:1px;
+  }
+  .l10n-table ul.actions li.declined label {
+    background:url(images/decline.png) no-repeat right top;
+  }
+  .l10n-table ul.actions li.edit label {
+    background:url(images/edit.png) no-repeat right top;
+  }
+  li.translation.is-declined > .actions li.declined label {
+    background-image:url(images/undecline.png);
+  }
+  .l10n-table ul.actions li.stable label {
+    background:url(images/stable-inactive.png) no-repeat right top;
+  }
+  li.translation.is-stable > .actions li.stable label,
+  li.translation:hover > .actions li label {
+    opacity:1;
+  }
+  li.translation.is-stable > .actions li.stable label {
+    background-image:url(images/stable-active.png);
+  }
+
+  .l10n-string > br {
+    display:block;
+    content:"";
+    height:1px;
+    background:#CCC;
+    margin:2px 0;
+  }
+
+li.translation .l10n-string,
+td.source .l10n-string {
+  display:block;
+  line-height:16px;
+}
+  li.translation .l10n-string > span,
+  td.source .l10n-string > span {
+    white-space:pre-wrap;
+    -webkit-transition: opacity 0.2s ease-out;
+  }
+  li.translation.is-declined .l10n-string {
+    color:#999;
+    height:16px;
+    overflow:hidden;
+    text-decoration:line-through;
+  }
+  li.translation.no-translation .l10n-string span {
+    font-style:italic;
+    color:#666;
+  }
+
+
+li.translation > .author {
+  font-size:10px;
+  padding-left:10px;
+  line-height:12px;
+  color:#999;
+}
+  li.translation.is-declined > .author {
+    display:none;
+  }
+  li.translation > .author a {
+    color:#666;
+  }
+  li.translation > .author span[title] {
+    cursor:pointer;
+  }
+
+
+  li.new-translation:not(.focussed):not(.has-content):not(.is-active) > .l10n-string {
+    display:none;
+  }
+  li.new-translation.has-content > .actions {
+      display:block;
+    }
+    li.new-translation .l10n-string {
+      display:block;
+      margin-bottom:4px;
+      margin-right:6px;
+    }
+    li.new-translation .l10n-string > span:after {
+      content:"*";
+      color:#E09010;
+    }
+    li.new-translation .l10n-string > span:empty:after {
+      content:attr(data-empty);
+      font-style:italic;
+      color:#666;
+    }
+  li.new-translation > .form-item ~ .form-item {
+    margin-top:5px;
+  }
+  li.new-translation:not(.has-content):not(.focussed) > .form-item ~ .form-item {
+    display:none;
+  }
+  li.new-translation textarea {
+    height:60px;
+    clear:left;
+    width:100%;
+    padding:0;
+    margin:0;
+    font-size:100%;
+    font-family:inherit;
+    -webkit-transition: height 0.2s ease-out;
+  }
+  li.new-translation:not(.focussed) textarea:not(:focus):hover {
+    opacity:0.75;
+  }
+  li.new-translation:not(.focussed) .grippie {
+    visibility:hidden;
+    margin-bottom:-9px;
+  }
+  li.new-translation textarea:not(:focus) {
+    height:19px !important;
+    overflow:hidden;
+    resize:none;
+  }
+  li.new-translation:not(.focussed) textarea {
+    opacity:0.5 !important;
+  }
+  li.new-translation.focussed textarea {
+    opacity:1;
+    position:relative;
+    z-index:1;
+  }
+
+
+
+
+
+span.l10n-escape,
+span.l10n-nl::after,
+em.l10n-placeholder,
+code
+ {
+  font-family:Menlo, Monaco, Consolas, "Lucida Console", monospace;
+  font-size:12px;
+  line-height:14px;
+}
+
+
+
+
+.l10n-string span.l10n-nl::after {
+  content:"¬";
+  opacity:0.4;
+  line-height:19px;
+  vertical-align:top;
+}
+
+
+.l10n-string code,
+.l10n-string em.l10n-placeholder {
+  background:transparent;
+  padding:0 1px;
+  margin:0 -1px;
+  -webkit-border-radius:4px;
+  -moz-border-radius:4px;
+  border:1px solid transparent;
+  font-style:normal;
+}
+
+tr:hover .l10n-string code,
+tr:hover .l10n-string em.l10n-placeholder {
+  background:rgba(0,0,0,0.05);
+}
+
+tr:hover .l10n-string em.l10n-placeholder {
+}
+
+tr:hover .l10n-string em.l10n-placeholder.highlight {
+border-color:rgba(0,0,0,0.2);
+  background:rgba(0,0,0,0.1);
+}
+
+.l10n-string code em.l10n-placeholder {
+  top:0;
+}
+
+.l10n-string span.worddiff-del del,
+.l10n-string span.worddiff-ins ins {
+  text-decoration: none;
+  border:1px solid transparent;
+  -webkit-border-radius:4px;
+  -moz-border-radius:4px;
+  padding:0;
+  margin:0 -1px;
+}
+
+.l10n-string > span.worddiff-del { background: #FFD8D8; }
+.l10n-string > span.worddiff-del del { background: #FF8888; }
+.l10n-string > span.worddiff-ins { background: #DDF8CC; }
+.l10n-string > span.worddiff-ins ins { background: #90F678; }
+
+.clearfix:after {
+  content: ".";
+  display: block;
+  height: 0;
+  clear: both;
+  visibility: hidden;
+  }
\ No newline at end of file
diff --git l10n_community/editor.js l10n_community/editor.js
new file mode 100644
index 0000000..5fb9f90
--- /dev/null
+++ l10n_community/editor.js
@@ -0,0 +1,202 @@
+
+(function ($) {
+  encode = function (str) {
+    str = String(str);
+    var replace = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
+    for (var character in replace) {
+      var regex = new RegExp(character, 'g');
+      str = str.replace(regex, replace[character]);
+    }
+    return str;
+  };
+
+  $('em.l10n-placeholder')
+    .live('mouseover', function () {
+      $(this).closest('tr').find('.l10n-placeholder:contains("' + $(this).text() + '")').addClass('highlight');
+    })
+    .live('mouseout', function () {
+      $('.l10n-placeholder.highlight').removeClass('highlight');
+    });
+
+  $(function () {
+    $('.l10n-more').click(function () {
+      $(this)
+        .addClass('loading')
+        .parent().load(this.href);
+      return false;
+    });
+
+    var markup = function (string) {
+      // Highlight placeholders.
+      string = string.replace(/([!@%]|<(ins|del)>[!@%]<\/(ins|del)>)(\w+|<(ins|del)>\w+<\/(ins|del)>)/g, '<em class="l10n-placeholder">$&</em>');
+
+      // Wrap HTML tags in <code> tags.
+      string = string.replace(/(&lt;.+?(&gt;|$))/g, function (str) {
+        return '<code>' + str.replace(/<[^>]+>/g, '</code>$&<code>') + '</code>';
+      });
+
+      string = string.replace(/\\[^<]/g, '<span class="l10n-escape">$&</span>');
+      string = string.replace(/\n/g, '<span class="l10n-nl"></span>$&');
+      return string;
+    };
+
+    $('td.translation').parent().each(function () {
+      var all = $('li.translation', this);
+      var strings = all.find('.l10n-string > span');
+      var source = $('td.source', this);
+
+      source.find('.l10n-string span').each(function () {
+        $(this).html(markup($(this).html()));
+      });
+
+      strings.each(function () {
+        var orig = $(this).html(), markedUp = markup(orig);
+        $(this)
+          .html(markedUp)
+          .data('worddiff:original', orig)
+          .data('worddiff:markup', markedUp);
+      });
+
+      var setStatus = function (elem, status, value) {
+        newValue = elem.find('.' + status + ' :checkbox').attr('checked', value).attr('checked');
+        elem[(newValue === undefined ? value : newValue) ? 'addClass' : 'removeClass']('is-' + status);
+      };
+
+      var textareas = all.filter('.new-translation').find('textarea');
+
+      $(this).find('ul.actions .edit').click(function () {
+        var translation = $(this).closest('td.source, li.translation');
+        var confirmed = undefined;
+        textareas.each(function (i) {
+          var textarea = $(this);
+          var val = textarea.val();
+          if (confirmed || val === textarea.attr('defaultValue') || !val || (confirmed === undefined && (confirmed = confirm("Do you want to overwrite the current suggestion?")))) {
+            textarea.val(translation.find('.l10n-string > span:eq('+ i +')').text()).keyup();
+            if (i == 0) {
+              // Since we can't have multiple focuses, we jut focus the first textarea.
+              textarea.focus();
+            }
+          }
+        });
+      });
+
+      all.each(function () {
+        var translation = $(this);
+        var isTranslation = !translation.is('.no-translation');
+        var siblings = all.not(this).not('.no-translation');
+
+        var removeDiff = function () {
+          strings.worddiffRevert();
+        };
+
+        var updateDiff = function () {
+          removeDiff();
+          if (isTranslation) {
+            var orig = siblings.filter('.is-active');
+            if (!orig.length)
+              orig = siblings.filter('.default');
+            if (!orig.length)
+              orig = all.not('.no-translation').eq(0).not(translation);
+            if (orig.length) {
+              orig = orig.find('.l10n-string > span');
+              translation.find('.l10n-string > span').each(function (i) {
+                $(this).worddiff(orig.get(i), markup);
+              });
+            }
+          }
+        };
+
+        translation.find('> .selector').click(function () {
+          setStatus(translation, 'declined', false);
+          // Mark this as the active translation.
+          setStatus(translation.siblings('.is-active:not(.new-translation)'), 'declined', true);
+          setStatus(translation.siblings('.is-active'), 'active', false);
+          translation.addClass('is-active');
+        });
+
+        translation.find('> .actions .declined :checkbox').change(function () {
+          setStatus(translation, 'declined', this.checked);
+        });
+
+        translation.find('> .actions .stable :checkbox').change(function () {
+          setStatus(translation, 'stable', this.checked);
+        });
+
+        translation.find('> .author span[title]').click(function () {
+          var $this = $(this), html = $this.html();
+          $this.html($this.attr('title')).attr('title', html);
+        });
+
+        if (isTranslation) {
+          translation.find('.l10n-string').dblclick(function () {
+            translation.siblings().not('.new-translation').each(function () {
+              setStatus($(this), 'declined', true);
+            });
+          });
+
+          translation
+            .mouseenter(updateDiff)
+            .mouseleave(removeDiff);
+        }
+
+        if (translation.is('.new-translation')) {
+          translation.find('> .selector').click(function () {
+            textareas.each(function () {
+              var textarea = $(this);
+              if (textarea.val() === '' || textarea.val() === textarea.attr('defaultValue')) {
+                textarea.focus();
+                // Stop checking the other ones.
+                return false;
+              }
+            });
+          });
+
+          var hasContent = function () {
+            for (var i = 0; i < textareas.length; i++) {
+              if (textareas[i].value && textareas[i].value !== textareas[i].defaultValue) {
+                return true;
+              }
+            }
+            return false;
+          };
+
+          var blurTimeout;
+          textareas.each(function (n) {
+            var wrapper = $(this);
+            var textarea = $(this);
+            var text = translation.find('.l10n-string > span').eq(n);
+
+            textarea
+              .focus(function () {
+                translation.addClass('focussed');
+                clearTimeout(blurTimeout);
+                if (textarea.val() === textarea.attr('defaultValue')) {
+                  textarea.val('');
+                }
+              })
+              .blur(function () {
+                blurTimeout = setTimeout(function () {
+                  translation.removeClass('focussed');
+                  if (textarea.val() === '') {
+                    textarea.val(textarea.attr('defaultValue'));
+                  }
+                  translation[hasContent() ? 'addClass' : 'removeClass']('has-content');
+                }, 1000);
+              })
+              .keyup(function () {
+                var val = encode(textarea.val());
+                text
+                  .data('worddiff:original', val)
+                  .data('worddiff:markup', markup(val));
+                var oldPos = textarea.offset().top;
+                updateDiff();
+                var diff = textarea.offset().top - oldPos;
+                if (diff)
+                  window.scrollBy(0, diff);
+              });
+          });
+        }
+      });
+    });
+  });
+})(jQuery);
diff --git l10n_community/extractor.inc l10n_community/extractor.inc
index 8f00f1f..06e16fd 100644
--- l10n_community/extractor.inc
+++ l10n_community/extractor.inc
@@ -200,7 +200,7 @@ function l10n_community_save_string($value = NULL, $context = NULL, $file = NULL
   if (isset($files[$file])) {
     // Explode files array to pid, rid and fid.
     list($pid, $rid, $fid) = $files[$file];
-    
+
     // A \0 separator in the string means we deal with a string with plural variants.
     // Unlike Drupal core, we store all in the same string, as it is easier
     // to handle later, and we don't need the individual string parts.
diff --git l10n_community/images/decline.png l10n_community/images/decline.png
new file mode 100755
index 0000000..c8a5058
Binary files /dev/null and l10n_community/images/decline.png differ
diff --git l10n_community/images/edit.png l10n_community/images/edit.png
new file mode 100755
index 0000000..37aced3
Binary files /dev/null and l10n_community/images/edit.png differ
diff --git l10n_community/images/icon_approval.gif l10n_community/images/icon_approval.gif
deleted file mode 100644
index b4290ba..0000000
Binary files l10n_community/images/icon_approval.gif and /dev/null differ
diff --git l10n_community/images/icon_copy.gif l10n_community/images/icon_copy.gif
deleted file mode 100644
index 4be8507..0000000
Binary files l10n_community/images/icon_copy.gif and /dev/null differ
diff --git l10n_community/images/icon_toolbox.gif l10n_community/images/icon_toolbox.gif
deleted file mode 100644
index 3c3f40e..0000000
Binary files l10n_community/images/icon_toolbox.gif and /dev/null differ
diff --git l10n_community/images/newline.png l10n_community/images/newline.png
deleted file mode 100644
index 5b7c3dc..0000000
Binary files l10n_community/images/newline.png and /dev/null differ
diff --git l10n_community/images/stable-active.png l10n_community/images/stable-active.png
new file mode 100755
index 0000000..53542e7
Binary files /dev/null and l10n_community/images/stable-active.png differ
diff --git l10n_community/images/stable-inactive.png l10n_community/images/stable-inactive.png
new file mode 100755
index 0000000..262915a
Binary files /dev/null and l10n_community/images/stable-inactive.png differ
diff --git l10n_community/images/undecline.png l10n_community/images/undecline.png
new file mode 100755
index 0000000..35ad7c2
Binary files /dev/null and l10n_community/images/undecline.png differ
diff --git l10n_community/import.inc l10n_community/import.inc
index cd86e98..041cfcf 100644
--- l10n_community/import.inc
+++ l10n_community/import.inc
@@ -129,10 +129,8 @@ function l10n_community_import_form_submit($form, &$form_state) {
 
     // Do the actual parsing on the local file.
     if (l10n_community_import($file, $form_state['values']['langcode'], $form_state['values']['is_suggestion'], $form_state['values']['import_uid'])) {
-      // Get status report on what was done in the process.
-      list($inserted, $updated, $unchanged, $suggested, $duplicates, $ignored) = _l10n_community_import_one_string();
       drupal_set_message(t('The translation was successfully imported.'));
-      l10n_community_update_message($inserted, $updated, $unchanged, $suggested, $duplicates, $ignored);
+      l10n_community_update_message();
       cache_clear_all('l10n:stats:'. $form_state['values']['langcode'], 'cache');
     }
   }
@@ -331,20 +329,10 @@ function l10n_community_import($file, $langcode, $is_suggestion, $uid) {
  * @param $uid
  *   User id used to save attribution information.
  */
-function _l10n_community_import_one_string($value = NULL, $langcode = NULL, $is_suggestion = FALSE, $uid = NULL) {
+function _l10n_community_import_one_string($value, $langcode = NULL, $is_suggestion = FALSE, $uid = NULL) {
   global $user;
 
-  static $inserted = 0;
-  static $updated = 0;
-  static $unchanged = 0;
-  static $suggested = 0;
-  static $duplicates = 0;
-  static $ignored = 0;
-
-  if ($value == NULL) {
-    // Result stats queried.
-    return array($inserted, $updated, $unchanged, $suggested, $duplicates, $ignored);
-  }
+  static $counters = array();
 
   // Trim translation (we will apply source string based whitespace later).
   if (is_string($value['msgstr'])) {
@@ -389,20 +377,20 @@ function _l10n_community_import_one_string($value = NULL, $langcode = NULL, $is_
 
       if ($is_suggestion || !$translation) {
         if (!l10n_community_is_duplicate($value['msgstr'], $sid, $langcode)) {
-          l10n_community_target_save($sid, $value['msgstr'], $langcode, $uid, $is_suggestion, $inserted, $updated, $unchanged, $suggested);
+          l10n_community_target_save($sid, $value['msgstr'], $langcode, $uid, $is_suggestion);
         }
         else {
-          $duplicates++;
+          l10n_community_counter('duplicates');
         }
       }
       else {
         // We certainly did not update this one.
-        $unchanged++;
+        l10n_community_counter('unchanged');
       }
     }
     else {
       // Source string not found, string ignored.
-      $ignored++;
+      l10n_community_counter('ignored');
     }
   }
 }
diff --git l10n_community/jquery.worddiff.js l10n_community/jquery.worddiff.js
new file mode 100644
index 0000000..3e2efb2
--- /dev/null
+++ l10n_community/jquery.worddiff.js
@@ -0,0 +1,183 @@
+(function($) {
+  var Fragment = function (string) {
+    this.content = string;
+    this.equiv = false;
+  };
+
+  Fragment.prototype.toString = function (tag) {
+    if (this.equiv || !tag)
+      return this.content;
+    else
+      return '<' + tag + '>' + this.content + '</' + tag + '>';
+  };
+
+
+  var moveToEnd = function (a, i, k) {
+    if (!a.equiv && (!k[i-1] || k[i-1].equiv)) {
+      // Find next item equiv item.
+      for (var j = i+1; k[j] && !k[j].equiv; j++);
+      if (k[j] && k[j].content === a.content) {
+        k[i] = k[j];
+        k[j] = a;
+      }
+    }
+  };
+
+  var aggregate = function (a, i, k) {
+    if (!a.equiv && k[i+1] && !k[i+1].equiv) {
+      k[i+1].content = a.content + k[i+1].content;
+      delete k[i];
+    }
+  };
+
+  var join = function (what, t) {
+    return $.map(what, function (a) {
+      if (a) return a.toString(t);
+    }).join('');
+  };
+
+
+  $.wordDiff = {
+    nonWord: /(&.+?;|[\u0000-\u0040\u005B-\u0060\u007B-\u00A9\u00AB-\u00B4\u00B6-\u00B9\u00BB-\u00BF\u00D7\u00F7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u036F\u0375\u037E\u0384\u0385\u0387\u03F6\u0482-\u0489\u055A-\u055F\u0589\u058A\u0591-\u05C7\u05F3\u05F4\u0600-\u0603\u0606-\u061B\u061E\u061F\u064B-\u065E\u0660-\u066D\u0670\u06D4\u06D6-\u06E4\u06EA-\u06ED\u06F0-\u06F9\u06FD\u06FE\u0700-\u070D\u070F\u0711\u0730-\u074A\u07A6-\u07B0\u07C0-\u07C9\u07EB-\u07F3\u07F6-\u07F9\u0901-\u0903\u093C\u093E-\u094D\u0951-\u0954\u09E2\u0962-\u0970\u06E7-\u06E9\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E3\u09E6-\u09EF\u09F2-\u09FA\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A66-\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AE6-\u0AEF\u0AF1\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B66-\u0B70\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0BE6-\u0BFA\u0C01-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C66-\u0C6F\u0C78-\u0C7F\u0C82\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0CE6-\u0CEF\u0CF1\u0CF2\u0D02\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D66-\u0D75\u0D79\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2-\u0DF4\u0E31\u0E34-\u0E3A\u0E3F\u0E47-\u0E5B\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0ED0-\u0ED9\u0F01-\u0F3F\u0F71-\u0F87\u0F90-\u0F97\u0F99-\u0FBC\u0FBE-\u0FCC\u0FCE-\u0FD4\u102B-\u103E\u1040-\u104F\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F-\u1099\u109E\u109F\u10FB\u135F-\u137C\u1390-\u1399\u166D\u166E\u1680\u169B\u169C\u16EB-\u16F0\u1712-\u1714\u1732-\u1736\u1752\u1753\u1772\u1773\u17B4-\u17D6\u17D8-\u17DB\u17DD\u17E0-\u17E9\u17F0-\u17F9\u1800-\u180E\u1810-\u1819\u18A9\u1920-\u192B\u1930-\u193B\u1940\u1944-\u194F\u19B0-\u19C0\u19C8\u19C9\u19D0-\u19D9\u19DE-\u19FF\u1A17-\u1A1B\u1A1E\u1A1F\u1B00-\u1B04\u1B34-\u1B44\u1B50-\u1B7C\u1B80-\u1B82\u1BA1-\u1BAA\u1BB0-\u1BB9\u1C24-\u1C37\u1C3B-\u1C49\u1C50-\u1C59\u1C7E\u1C7F\u1DC0-\u1DE6\u1DFE\u1DFF\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD\u1FFE\u2000-\u2064\u206A-\u2070\u2074-\u207E\u2080-\u208E\u20A0-\u20B5\u20D0-\u20F0\u2100\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F\u2153-\u2182\u2185-\u2188\u2190-\u23E7\u2400-\u2426\u2440-\u244A\u2460-\u269D\u26A0-\u26BC\u26C0-\u26C3\u2701-\u2704\u2706-\u2709\u270C-\u2727\u2729-\u274B\u274D\u274F-\u2752\u2756\u2758-\u275E\u2761-\u2794\u2798-\u27AF\u27B1-\u27BE\u27C0-\u27CA\u27CC\u27D0-\u2B4C\u2B50-\u2B54\u2CE5-\u2CEA\u2CF9-\u2CFF\u2DE0-\u2E2E\u2E30\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u2FFB\u3000-\u3004\u3007-\u3030\u3036-\u303A\u303D-\u303F\u3099-\u309C\u30A0\u30FB\u3190-\u319F\u31C0-\u31E3\u3200-\u321E\u3220-\u3243\u3250-\u32FE\u3300-\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA60D-\uA60F\uA620-\uA629\uA66F-\uA673\uA67C-\uA67E\uA700-\uA716\uA720\uA721\uA789\uA78A\uA802\uA806\uA80B\uA823-\uA82B\uA874-\uA877\uA880\uA881\uA8B4-\uA8C4\uA8CE-\uA8D9\uA900-\uA909\uA926-\uA92F\uA947-\uA953\uA95F\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA50-\uAA59\uAA5C-\uAA5F\uD800\uDB7F\uDB80\uDBFF\uDC00\uDFFF\uE000\uF8FF\uFB1E\uFB29\uFD3E\uFD3F\uFDFC\uFDFD\uFE00-\uFE19\uFE20-\uFE26\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFEFF\uFF01-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65\uFFE0-\uFFE6\uFFE8-\uFFEE\uFFF9-\uFFFD])/,
+
+    tokenize: function (args) {
+      // Split on non-word characters.
+      for (var type in args) {
+        args[type] = $.grep(
+          args[type].split($.wordDiff.nonWord),
+          function (s) { return s.length; }
+        );
+      }
+
+      // Calculate the indexes and offsets for common suffixes and prefixes.
+      var i = -1, j = args.del.length, k = args.ins.length;
+      while (args.del[++i] === args.ins[i] && i <= j);
+      while (j >= i && k >= i && args.del[--j] === args.ins[--k]);
+
+      args.prefix = args.del.slice(0, i).join('');
+      args.suffix = args.del.slice(j + 1).join('');
+      args.del = args.del.slice(i, ++j);
+      args.ins = args.ins.slice(i, ++k);
+    },
+
+    lcs: function (args) {
+      var matrix = [];
+
+      for (var i = 0; i < args.del.length; i++) {
+        matrix[i] = [];
+        for (var j = 0; j < args.ins.length; j++) {
+          if (args.del[i] === args.ins[j]) {
+            matrix[i][j] = (matrix[i - 1] && matrix[i - 1][j - 1] || 0) + args.del[i].length;
+          }
+          else {
+            matrix[i][j] = Math.max(matrix[i][j - 1] || 0, matrix[i - 1] && matrix[i - 1][j] || 0);
+          }
+        }
+      }
+
+      return matrix;
+    },
+
+    changeset: function (args, matrix) {
+      var result = {};
+
+      $.each(['del', 'ins'], function () {
+        result[this] = $.map(args[this], function (a) { return new Fragment(a); });
+      });
+
+      // Backtrack through the matrix.
+      for (var i = result.del.length - 1, j = result.ins.length - 1; i >= 0; i--, j--) {
+        if (j < 0 || result.del[i].content !== result.ins[j].content) {
+          if (j < 0 || (j > 0 && matrix[i - 1] && (matrix[i][j - 1] < matrix[i - 1][j]))) {
+            j++;
+          }
+          else {
+            i++;
+          }
+        }
+        else {
+          result.del[i] = result.ins[j];
+          result.del[i].equiv = true;
+        }
+      }
+
+      // Fill up gaps.
+      for (var i = 0; i < result.del.length; i++) {
+        if (result.del[i].equiv && result.del[i].content.length < 3) {
+          var j = result.ins.indexOf(result.del[i]);
+          if (result.del[i-1] && result.del[i+1] && result.ins[j-1] && result.ins[j+1] && !result.del[i-1].equiv && !result.del[i+1].equiv && !result.ins[j-1].equiv && !result.ins[j+1].equiv){
+            result.del[i].equiv = false;
+            result.ins[j] = $.extend(true, {}, result.del[i]);
+          }
+        }
+      }
+
+      $.each(['del', 'ins'], function () {
+        // Try to move changes to the end.
+        for (var i = 0; i < result[this].length; i++)
+          moveToEnd(result[this][i], i, result[this]);
+
+        // Aggregate subsequent changes to minimize ins/del tags.
+        for (var i = 0; i < result[this].length; i++)
+          aggregate(result[this][i], i, result[this]);
+      });
+
+      return result;
+    },
+
+    render: function (args, result) {
+      var diff = {
+        del: args.prefix + join(result.del, 'del') + args.suffix,
+        ins: args.prefix + join(result.ins, 'ins') + args.suffix
+      };
+
+      return diff;
+    },
+
+    diff: function (del, ins) {
+      var args = { 'del': del, 'ins': ins };
+
+      $.wordDiff.tokenize(args);
+      var matrix = $.wordDiff.lcs(args);
+      var result = $.wordDiff.changeset(args, matrix);
+      return $.wordDiff.render(args, result);
+    }
+  };
+
+  $.fn.worddiff = function (selector, processor) {
+    var src = $(selector), del = src.data('worddiff:original');
+    if (del === undefined)
+      src.data('worddiff:original', del = src.html());
+
+    return this.each(function () {
+      var dst = $(this), ins = dst.data('worddiff:original');
+      if (ins === undefined)
+        dst.data('worddiff:original', ins = dst.html());
+
+      var diff = $.wordDiff.diff(del, ins);
+      if (processor) {
+        diff.del = processor(diff.del);
+        diff.ins = processor(diff.ins);
+      }
+
+      if (diff.del && diff.ins) {
+        src.html(diff.del).addClass('worddiff-del');
+        dst.html(diff.ins).addClass('worddiff-ins');
+      }
+    });
+  };
+
+  $.fn.worddiffRevert = function () {
+    return this.each(function () {
+      var dest = $(this), orig = dest.data('worddiff:markup');
+      if (orig === undefined) {
+        orig = dest.data('worddiff:original');
+      }
+      if (orig !== undefined) {
+        dest
+          .html(orig)
+          .removeClass('worddiff-del').removeClass('worddiff-ins');
+      }
+    });
+  };
+})(jQuery);
\ No newline at end of file
diff --git l10n_community/l10n_community.css l10n_community/l10n_community.css
index 1db630a..98fd2eb 100644
--- l10n_community/l10n_community.css
+++ l10n_community/l10n_community.css
@@ -62,147 +62,3 @@ table.l10n-server-filter td.last {
 em.l10n-community-marker {
   font-weight: bold;
 }
-
-/* Translation editor styles */
-#l10n-community-translate-view .pager,
-#l10n-community-translate-form .pager {
-  margin:1em 0em;
-}
-
-table.l10n-server-translate .hidden {
-  display:none;
-}
-
-table.l10n-server-translate input.form-text,
-table.l10n-server-translate textarea {
-  width:95%;
-}
-
-table.l10n-server-translate td {
-  vertical-align:top;
-  width:50%;
-}
-
-  table.l10n-server-translate td.source {
-    padding:10px 5px;
-  }
-
-  table .string-context {
-    color: #777;
-    font-size: 0.8em;
-  }
-
-  table.l10n-server-translate td.translation {
-    padding:0px;
-  }
-
-  table.l10n-server-translate td .l10n-panes {
-    min-height:60px;
-    position:relative;
-    padding:0px 20px 0px 0px;
-  }
-
-  table.l10n-server-translate td .pane {
-    padding:10px 5px;
-  }
-
-  table.l10n-server-translate td .suggestions,
-  table.l10n-server-translate td .lookup {
-    display:none;
-  }
-
-table.l10n-server-translate .toolbox {
-  position:absolute; top:0px; right:0px;
-}
-
-table.l10n-server-translate tr .form-item {
-  white-space: normal;
-}
-
-table.l10n-server-translate label.option {
-  font-size:90%;
-}
-
-.l10n-button {
-  cursor:pointer;
-  display: block;
-  width: 20px;
-  height: 20px;
-  overflow: hidden;
-  text-indent: -9999px;
-}
-
-  .l10n-approval-buttons {
-    padding-right:10px;
-    float:right;
-  }
-
-  .l10n-approval-buttons .l10n-button {
-    float:left;
-  }
-
-  .l10n-approval-buttons .l10n-approve,
-  .l10n-save {
-    background: url(images/icon_approval.gif) 0px 0px no-repeat;
-  }
-
-    .l10n-approval-buttons .l10n-approve:active,
-    .l10n-save:active {
-      background-position: 0px bottom;
-    }
-
-  .l10n-approval-buttons .l10n-decline,
-  .l10n-clear {
-    width:21px;
-    background: url(images/icon_approval.gif) -20px 0px no-repeat;
-  }
-
-    .l10n-approval-buttons .l10n-decline:active,
-    .l10n-clear:active {
-      background-position: -20px bottom;
-    }
-
-  .l10n-community-copy {
-    background: url(images/icon_copy.gif) no-repeat;
-  }
-
-  .l10n-translate {
-    background: url(images/icon_toolbox.gif) 0px 0px no-repeat;
-  }
-
-    .l10n-translate.active {
-      background: url(images/icon_toolbox.gif) right 0px no-repeat;
-    }
-
-  .l10n-suggestions {
-    background: url(images/icon_toolbox.gif) 0px -40px no-repeat;
-  }
-
-    .l10n-suggestions.active {
-      background: url(images/icon_toolbox.gif) right -40px no-repeat;
-    }
-
-  .l10n-lookup {
-    background: url(images/icon_toolbox.gif) 0px -20px no-repeat;
-  }
-
-    .l10n-lookup.active {
-      background: url(images/icon_toolbox.gif) right -20px no-repeat;
-    }
-
-ul.l10n-community-strings {
-  margin: 0;
-}
-
-ul.l10n-community-strings li {
-  padding-left: 20px;
-}
-
-ul.l10n-community-strings li .buttons {
-  margin-left:-20px;
-  float:left;
-}
-
-.l10n-community-string .original {
-  display:none;
-}
diff --git l10n_community/l10n_community.info l10n_community/l10n_community.info
index 07c0ddd..977e6df 100644
--- l10n_community/l10n_community.info
+++ l10n_community/l10n_community.info
@@ -2,6 +2,7 @@
 name = "Localization community"
 description = A community interface for string translation
 dependencies[] = locale
+dependencies[] = jquery_update
 package = "Localization server"
 core = 6.x
 php = 5
diff --git l10n_community/l10n_community.js l10n_community/l10n_community.js
deleted file mode 100644
index e96bf79..0000000
--- l10n_community/l10n_community.js
+++ /dev/null
@@ -1,220 +0,0 @@
-// $Id: l10n_community.js,v 1.1.2.8.2.5 2009-10-15 08:46:40 goba Exp $
-
-l10nCommunity = {};
-
-l10nCommunity.switchPanes = function(elem, id) {
-  if ($(elem).parents('.translation').find('.'+id).css('display') == 'none') {
-    // execute animation once per translation
-    var once = 0;
-    $(elem).parents('.translation').find('.pane:not(.'+id+')').each(function() {
-      if ($(this).css('display') == 'block') {
-        $(this).slideUp('fast', function() {
-          if (once == 0) {
-            once = 1;
-            $(this).parents('.translation').find('.'+id).slideDown('fast');
-          }
-        });
-      }
-    });
-  }
-  $(elem).parents('.toolbox').find('span.l10n-button').removeClass('active');
-  $(elem).addClass('active');
-}
-
-/**
- * Initialize action images: toolboxes and string copy buttons.
- */
-l10nCommunity.init = function() {
-  // Only attempt to register events if form exists
-  if ($('#l10n-community-translate-form').size() > 0) {
-    // When the copy button is clicked, copy the original string value to the
-    // translation field for the given strings. Relations are maintained with
-    // the string ideitifiers.
-    $('span.l10n-community-copy').click(function() {
-      l10nCommunity.copyString(this);
-    ;})
-
-    // Screw AJAX submit -- we'll just hard submit the form for now
-    $('span.l10n-save').click(function() {
-      $('#l10n-community-translate-form').submit();
-    });
-
-    // Clear sibling text fields
-    $('span.l10n-clear').click(function() {
-      $(this).parents('.translation').find('input.form-text, textarea').val('');
-    });
-
-
-    $('#l10n-community-translate-form .l10n-translate').click(function() {
-      // switch display panes
-      l10nCommunity.switchPanes(this, 'translate');
-    });
-
-    $('#l10n-community-translate-form .l10n-lookup').click(function() {
-      // switch display panes
-      var elem = this;
-      if ($(this).is(".active")) {
-        // Switch back to editing form if already clicked. Convenience feature,
-        // so that you don't need to move your mouse to switch back.
-        var parent = $(elem).parents('.translation');
-        var tool = $('.l10n-translate', parent);
-        l10nCommunity.switchPanes(tool, 'translate');
-        return;
-      }
-      var sid = $(this).parents('.translation').attr('id').substring(6);
-      $.get(Drupal.settings.l10n_details_callback + sid, null, function(data) {
-        $('#tpane-' + sid + ' .lookup').empty().append(data);
-        l10nCommunity.switchPanes(elem, 'lookup');
-      });
-    });
-
-    $('#l10n-community-translate-form .l10n-suggestions').click(function() {
-      // switch display panes
-      var elem = this;
-      if ($(this).is(".active")) {
-        // Switch back to editing form if already clicked. Convenience feature,
-        // so that you don't need to move your mouse to switch back.
-        var parent = $(elem).parents('.translation');
-        var tool = $('.l10n-translate', parent);
-        l10nCommunity.switchPanes(tool, 'translate');
-        return;
-      }
-      var sid = $(this).parents('.translation').attr('id').substring(6);
-      $.get(Drupal.settings.l10n_suggestions_callback + sid, null, function(data) {
-        $('#tpane-' + sid + ' .suggestions').empty().append(data);
-        l10nCommunity.switchPanes(elem, 'suggestions');
-        var suggestions = $('#tpane-' + sid + ' .suggestions');
-        $('span.l10n-community-copy', suggestions).click(function() {
-          l10nCommunity.copyString(this);
-        });
-        // Hide the has-suggestion marker if we don't have suggestions anymore.
-        // Could happen if we declined the last suggestion and reloading.
-        $('#l10n-community-editor-' + sid + ' .l10n-no-suggestions').parent().parent().find('.l10n-has-suggestion').hide();
-      });
-    });
-  }
-}
-
-l10nCommunity.copyString = function(elem) {
-  var item = $(elem).parents('li').find('div.string > div');
-  var parentlist = $(item).parents('ul.l10n-community-strings');
-  var original = $('.original', item).text();
-  var sid = item.attr('class').substring(7);
-
-  if (sid.indexOf('-') > 0) {
-    // Copying orignal plural string or active plural translation. We should
-    // get the original sid prefix and then copy the strings to the textareas.
-    sid = sid.split('-');
-    sid = sid[0];
-    for (i = 0; i < Drupal.settings.l10n_num_plurals; i++) {
-      source_index = (i > 0 ? 1 : 0);
-      $('#l10n-community-translation-'+ sid +'-'+ i).val($('.string-'+ sid +'-'+ source_index +' .original', parentlist).text());
-    }
-  }
-  else if (original.indexOf(";  ") > 0)  {
-    // Copying plural suggestions from the suggestion list. This has the special
-    // ";  " delimiter (which we suppose does not exist in source strings).
-    var strings = original.split(";  ");
-    for (string in strings) {
-      $('#l10n-community-translation-'+ sid +'-'+ string).val(strings[string]);
-    }
-  }
-  else {
-    // Otherwise simple string. Just copy over the original string.
-    $('#l10n-community-translation-' + sid).val(original);
-  }
-
-  // Show the editing controls.
-  $('#l10n-community-wrapper-' + sid).show();
-
-  // Switch to translate pane. The pane is in the translation column, so
-  // if we are in the source column, we need to switch columns.
-  var parent = $(elem).parents('td.source, td.translation');
-  if (parent.get(0).className == 'source') {
-    parent = $(parent.get(0)).siblings();
-  }
-  var tool = $('.l10n-translate', parent);
-  l10nCommunity.switchPanes(tool, 'translate');
-}
-
-/**
- * Suggestion approval callback.
- */
-l10nCommunity.approveSuggestion = function(tid, sid, elem, token) {
-  // Invoke server side callback to save the approval.
-  $.ajax({
-    type: "GET",
-    url: Drupal.settings.l10n_approve_callback + tid + Drupal.settings.l10n_form_token_path + token,
-    success: function (data) {
-      if (data == 'done') {
-        // Empty translate pane and inform user that the suggestion was saved.
-        $('#tpane-'+ sid +' .translate').empty().append(Drupal.settings.l10n_approve_confirm);
-        // Switch back to translate pane and hide suggestion icon
-        var parent = $(elem).parents('.translation');
-        l10nCommunity.switchPanes($('.l10n-translate', parent), 'translate');
-        $('.l10n-suggestions', parent).hide();
-      }
-      else {
-        alert(Drupal.settings.l10n_approve_error);
-      };
-    },
-    error: function (xmlhttp) {
-      // Being an internal/system error, this is not translatable.
-      alert('An HTTP error '+ xmlhttp.status +' occured.\n'+ uri);
-    }
-  });
-  // Return false for onclick handling.
-  return false;
-}
-
-/**
- * Suggestion decline callback.
- */
-l10nCommunity.declineSuggestion = function(tid, sid, elem, token) {
-  // Invoke server side callback to save the decline action.
-  $.ajax({
-    type: "GET",
-    url: Drupal.settings.l10n_decline_callback + tid + Drupal.settings.l10n_form_token_path + token,
-    success: function (data) {
-      if (data == 'done') {
-        // Reload info pane.
-        $('#tpane-' + sid + ' .l10n-suggestions').click();
-      }
-      else {
-        alert(Drupal.settings.l10n_decline_error);
-      };
-    },
-    error: function (xmlhttp) {
-      // Being an internal/system error, this is not translatable.
-      alert('An HTTP error '+ xmlhttp.status +' occured.\n'+ uri);
-    }
-  });
-  // Return false for onclick handling.
-  return false;
-}
-
-/**
- * Suggestion editing copy callback.
- */
-l10nCommunity.copySuggestion = function(sid, translation) {
-  if (translation.indexOf("\O") > 0) {
-    // If we have the null byte, suggestion has plurals, so we need to
-    // copy over the distinct strings to the distinct textareas.
-    var strings = translation.split("\O");
-    for (string in strings) {
-      $('#l10n-community-translation-'+ sid +'-'+ string).val(strings[string]);
-    }
-  }
-  else {
-    // Otherwise standard string.
-    $('#l10n-community-translation-'+ sid).val(translation);
-  }
-  // Show the editing controls.
-  $('#l10n-community-wrapper-'+ sid).css('display', 'block');
-  return false;
-}
-
-// Global killswitch
-if (Drupal.jsEnabled) {
-  $(document).ready(l10nCommunity.init);
-}
diff --git l10n_community/l10n_community.module l10n_community/l10n_community.module
index bbd3de7..3d32f95 100644
--- l10n_community/l10n_community.module
+++ l10n_community/l10n_community.module
@@ -207,33 +207,10 @@ function l10n_community_menu() {
   $items['translate/details'] = array(
     'title' => 'String details',
     'page callback' => 'l10n_community_string_details',
-    'file' => 'ajax.inc',
-    'access arguments' => array('browse translations'),
-    'type' => MENU_CALLBACK,
-  );
-  $items['translate/suggestions'] = array(
-    'title' => 'String suggestions',
-    'page callback' => 'l10n_community_string_suggestions',
-    'file' => 'ajax.inc',
+    'file' => 'translate.inc',
     'access arguments' => array('browse translations'),
     'type' => MENU_CALLBACK,
   );
-  $items['translate/approve'] = array(
-    'title' => 'Approve suggestion',
-    'page callback' => 'l10n_community_string_approve',
-    'file' => 'ajax.inc',
-    // Permission is enforced in l10n_community_string_ajax_suggestion().
-    'access arguments' => array('access localization community'),
-    'type' => MENU_CALLBACK,
-  );
-  $items['translate/decline'] = array(
-    'title' => 'Decline suggestion',
-    'page callback' => 'l10n_community_string_decline',
-    'file' => 'ajax.inc',
-    // Permission is enforced in l10n_community_string_ajax_suggestion().
-    'access arguments' => array('access localization community'),
-    'type' => MENU_CALLBACK,
-  );
 
   // As soon as we have a language code, we can translate.
   $items['translate/languages/%l10n_community_language'] = array(
@@ -251,8 +228,8 @@ function l10n_community_menu() {
     'weight' => -20,
   );
   // Tabs to translate, import and export projects.
-  $items['translate/languages/%l10n_community_language/view'] = array(
-    'title' => 'Browse',
+  $items['translate/languages/%l10n_community_language/translate'] = array(
+    'title' => 'Translate',
     'page callback' => 'l10n_community_translate_page',
     'page arguments' => array(2),
     'file' => 'translate.inc',
@@ -261,16 +238,6 @@ function l10n_community_menu() {
     'type' => MENU_LOCAL_TASK,
     'weight' => -10,
   );
-  $items['translate/languages/%l10n_community_language/edit'] = array(
-    'title' => 'Translate',
-    'page callback' => 'l10n_community_translate_page',
-    'page arguments' => array(2, 'edit'),
-    'file' => 'translate.inc',
-    'access callback' => 'l10n_community_has_permission',
-    'access arguments' => array(2, (string)L10N_PERM_SUGGEST),
-    'type' => MENU_LOCAL_TASK,
-    'weight' => -8,
-  );
   $items['translate/languages/%l10n_community_language/moderate'] = array(
     'title' => 'Moderate',
     'page callback' => 'l10n_community_moderate_page',
@@ -496,6 +463,7 @@ function l10n_community_block_help() {
       // We are dealing with a groups based permission model.
       $permission_help[] = l10n_groups_block_help($perm, isset($args['langcode']) ? $args['langcode'] : NULL);
     }
+
     if ($perm & L10N_PERM_SUGGEST) {
       $permission_notes[] = t('You can suggest translations to be reviewed by moderators of the team.');
     }
@@ -949,6 +917,19 @@ function l10n_community_get_languages($key = NULL) {
 }
 
 /**
+ * Returns a language object for a specific language.
+ *
+ * @param $langcode
+ *   Language code, for example 'hu', 'pt-br', 'de' or 'it'.
+ * @return
+ *   A populated language object.
+ */
+function l10n_community_get_language($langcode) {
+  $languages = l10n_community_get_languages();
+  return $languages[$langcode];
+}
+
+/**
  * Get translation permission level for a specific user.
  *
  * @param $langcode
@@ -958,7 +939,7 @@ function l10n_community_get_languages($key = NULL) {
  * @return
  *   A bitmask of all permission flags the user has.
  */
-function l10n_community_get_permission($langcode, $account = NULL) {
+function l10n_community_get_permission($langcode, $account = NULL, $check = NULL) {
   static $permissions = array();
   global $user;
 
@@ -1000,12 +981,47 @@ function l10n_community_get_permission($langcode, $account = NULL) {
  * @param $langcode
  *   Language code, for example 'hu', 'pt-br', 'de' or 'it'.
  * @param $permission
- *   The permission to check for.
+ *   The permission to check for. Note that if you pass multiple permission
+ *   constants, this function will return TRUE if the user has at least one
+ *   of them.
  * @return
  *   TRUE if the user has at least one of the specified permissions.
  */
 function l10n_community_has_permission($langcode, $permission) {
-  return (bool)(l10n_community_get_permission($langcode, NULL) & $permission);
+  return (bool)(l10n_community_get_permission($langcode) & $permission);
+}
+
+/**
+ * Checks whether the current user may approve a translation.
+ *
+ * @param $translation
+ *   The translation in question.
+ * @return
+ *   TRUE if the user may approve the translation.
+ */
+function l10n_community_may_approve_translation($translation) {
+  global $user;
+  $required = $translation->uid_entered == $user->uid ? L10N_PERM_MODERATE_OWN : L10N_PERM_MODERATE_OTHERS;
+  return l10n_community_has_permission($translation->language, $required);
+}
+
+/**
+ * Checks whether the current user may decline a translation.
+ *
+ * @param $translation
+ *   The translation in question.
+ * @return
+ *   TRUE if the user may decline the translation.
+ */
+function l10n_community_may_decline_translation($translation) {
+  global $user;
+  if ($translation->uid_entered == $user->uid) {
+    // Everyone may decline their own translations.
+    return TRUE;
+  }
+  else {
+    return l10n_community_has_permission($translation->language, L10N_PERM_MODERATE_OTHERS);
+  }
 }
 
 /**
@@ -1157,31 +1173,30 @@ function l10n_community_get_contexts() {
  *
  * @see l10n_community_is_duplicate()
  */
-function l10n_community_target_save($sid, $translation, $langcode, $uid, $suggestion, &$inserted, &$updated, &$unchanged, &$suggested) {
+function l10n_community_target_save($sid, $translation, $langcode, $uid, $suggestion) {
 
   // Look for an existing active translation, if any.
   $existing_string = db_fetch_object(db_query("SELECT sid, tid, translation FROM {l10n_community_translation} WHERE sid = %d AND language = '%s' AND is_suggestion = 0 AND is_active = 1", $sid, $langcode));
 
   if (!empty($existing_string->sid)) {
-
     // We have an active translation.
     if ($existing_string->translation != $translation) {
       // And what we should save now is different.
       if ($suggestion) {
         // Saving a suggestion, so set flag on translation.
         db_query("UPDATE {l10n_community_translation} SET has_suggestion = 1 WHERE tid = %d", $existing_string->tid);
-        $suggested++;
+        l10n_community_counter('suggested');
       }
       else {
         // Saving a different translation -> deactivate previous translations and suggestions.
         db_query("UPDATE {l10n_community_translation} SET is_active = 0 WHERE sid = %d AND language = '%s';", $sid, $langcode);
-        $updated++;
+        l10n_community_counter('updated');
       }
       db_query("INSERT INTO {l10n_community_translation} (sid, translation, language, uid_entered, time_entered, uid_approved, time_approved, is_suggestion, is_active) VALUES (%d, '%s', '%s', %d, %d, %d, %d, %d, 1)", $sid, $translation, $langcode, $uid, time(), ($suggestion ? 0 : $uid), ($suggestion ? 0 : time()), $suggestion);
     }
     else {
       // Same string as existing translation.
-      $unchanged++;
+      l10n_community_counter('unchanged');
     }
   }
 
@@ -1192,12 +1207,12 @@ function l10n_community_target_save($sid, $translation, $langcode, $uid, $sugges
       // suggestions. We track and exclude these by translation = '' later.
       db_query("INSERT INTO {l10n_community_translation} (sid, translation, language, uid_entered, time_entered, has_suggestion, is_active) VALUES (%d, '', '%s', 0, %d, 1, 1)", $sid, $langcode, time());
       db_query("INSERT INTO {l10n_community_translation} (sid, language, translation, uid_entered, time_entered, is_suggestion, is_active) VALUES (%d, '%s', '%s', %d, %d, 1, 1)", $sid, $langcode, $translation, $uid, time());
-      $suggested++;
+      l10n_community_counter('suggested');
     }
     else {
       // No active translation yet -> INSERT.
       db_query("INSERT INTO {l10n_community_translation} (sid, translation, language, uid_entered, uid_approved, time_entered, time_approved, is_active) VALUES (%d, '%s', '%s', %d, %d, %d, %d, 1)", $sid, $translation, $langcode, $uid, $uid, time(), time());
-      $inserted++;
+      l10n_community_counter('added');
     }
   }
 }
@@ -1222,34 +1237,68 @@ function l10n_community_trim($translation, $source) {
 }
 
 /**
+ * Stores counters for status messages when modifying translations.
+ *
+ * @param $field
+ *   The field to increment. Can be one of declined, approved, inserted,
+ *   suggested, duplicates', ignored or unchanged.
+ *   If not specified, the counters are returned and reset afterwards.
+ * @param $increment
+ *   (Optional) The increment for the counter. Defaults to 1.
+ */
+function l10n_community_counter($field = NULL, $increment = 1) {
+  static $counters = array();
+  if (isset($field)) {
+    if (!isset($counters[$field])) {
+      $counters[$field] = 0;
+    }
+    $counters[$field] += $increment;
+  }
+  else {
+    $return = $counters;
+    $counters = array();
+    return $return;
+  }
+}
+
+/**
  * Set a message based on the number of translations changed.
  *
  * Used by both the save and import process.
  */
-function l10n_community_update_message($inserted, $updated, $unchanged, $suggested, $duplicates, $ignored) {
-  // Inform user about changes made.
-  $message = array();
-  if ($inserted) {
-    $message[] = format_plural($inserted, '1 new translation added', '@count new translations added');
-  }
-  if ($suggested) {
-    $message[] = format_plural($suggested, '1 new suggestion added', '@count new suggestions added');
-  }
-  if ($updated) {
-    $message[] = format_plural($updated, '1 translation updated', '@count translations updated');
-  }
-  if ($unchanged) {
-    $message[] = format_plural($unchanged, '1 translation unchanged', '@count translations unchanged');
-  }
-  if ($duplicates) {
-    $message[] = format_plural($duplicates, '1 duplicate translation not saved', '@count duplicate translations not saved');
-  }
-  if ($ignored) {
-    $message[] = format_plural($ignored, '1 source string not found, its translation ignored', '@count source strings not found, their translations were ignored');
-  }
-  if (count($message)) {
-    drupal_set_message(join('; ', $message) .'.');
-  }
+function l10n_community_update_message() {
+  $counters = l10n_community_counter();
+  $messages = array();
+
+  if (!empty($counters['declined']))
+    $messages[] = format_plural($counters['declined'], '1 translation declined', '@count translations declined');
+
+  if (!empty($counters['suggestion_declined']))
+    $messages[] = format_plural($counters['suggestion_declined'], '1 suggestion declined', '@count suggestion declined');
+
+  if (!empty($counters['approved']))
+    $messages[] = format_plural($counters['approved'], '1 translation approved', '@count translations approved');
+
+  if (!empty($counters['added']))
+    $messages[] = format_plural($counters['added'], '1 translation added', '@count translations added');
+
+  if (!empty($counters['suggested']))
+    $message[] = format_plural($counters['suggested'], '1 new suggestion added', '@count new suggestions added');
+
+  if (!empty($counters['updated']))
+    $message[] = format_plural($counters['updated'], '1 translation updated', '@count translations updated');
+
+  if (!empty($counters['duplicates']))
+    $message[] = format_plural($counters['duplicates'], '1 duplicate translation not saved', '@count duplicate translations not saved');
+
+  if (!empty($counters['ignored']))
+    $message[] = format_plural($counters['ignored'], '1 source string not found; its translation was ignored', '@count source strings not found; their translations were ignored');
+
+  if (!empty($counters['unchanged']))
+    $message[] = format_plural($counters['unchanged'], '1 translation unchanged', '@count translations unchanged');
+
+  if ($messages)
+    drupal_set_message(implode(', ', $messages));
 }
 
 /**
@@ -1310,15 +1359,6 @@ function l10n_community_project_uri_by_title($title) {
   return db_result(db_query("SELECT uri FROM {l10n_community_project} WHERE title = '%s'", $title));
 }
 
-/**
- * Check whether $suggestion is duplicate for $sid in $langcode.
- */
-function l10n_community_is_duplicate($suggestion, $sid, $langcode) {
-  // Use BINARY matching to avoid marking case-corrections as duplicate.
-  // Matches everything active, regardless of being translations or suggestions.
-  return (bool) db_result(db_query("SELECT s.sid FROM {l10n_community_string} s LEFT JOIN {l10n_community_translation} t ON s.sid = t.sid WHERE t.translation = BINARY '%s' AND t.is_active = 1 AND t.language = '%s' AND s.sid = %d", $suggestion, $langcode, $sid));
-}
-
 // = Theme functions ===========================================================
 
 /**
@@ -1326,16 +1366,6 @@ function l10n_community_is_duplicate($suggestion, $sid, $langcode) {
  */
 function l10n_community_theme($existing, $type, $theme, $path) {
   return array(
-    // l10n_community.module
-    'l10n_community_button' => array(
-      'arguments' => array('type' => NULL, 'class' => NULL, 'extras' => ''),
-    ),
-    'l10n_community_strings' => array(
-      'arguments' => array('items' => NULL, 'form' => TRUE),
-    ),
-    'l10n_community_copy_button' => array(
-      'arguments' => array(),
-    ),
     // pages.inc
     'l10n_community_progress_columns' => array(
       'arguments' => array('sum' => NULL, 'translated' => NULL, 'has_suggestion' => NULL),
@@ -1350,12 +1380,27 @@ function l10n_community_theme($existing, $type, $theme, $path) {
     'l10n_community_filter_form' => array(
       'arguments' => array('form' => NULL),
     ),
-    'l10n_community_translate_form' => array(
-      'arguments' => array('form' => NULL),
+    'l10n_community_translate_translation' => array(
+      'arguments' => array('element' => NULL),
+    ),
+    'l10n_community_translate_actions' => array(
+      'arguments' => array('element' => NULL),
     ),
     'l10n_community_in_context' => array(
       'arguments' => array('source' => NULL),
     ),
+    'l10n_community_translate_radio' => array(
+      'arguments' => array('element' => NULL),
+    ),
+    'l10n_community_translate_source' => array(
+      'arguments' => array('element' => NULL),
+    ),
+    'l10n_community_translate_translation_list' => array(
+      'arguments' => array('element' => NULL),
+    ),
+    'l10n_community_translate_table' => array(
+      'arguments' => array('element' => NULL),
+    ),
     // l10n_community.admin.inc
     'l10n_community_admin_projects_form' => array(
       'arguments' => array('form' => NULL),
@@ -1371,77 +1416,6 @@ function l10n_community_theme($existing, $type, $theme, $path) {
 }
 
 /**
- * Theme a textual button.
- *
- * Text values are centralized here so it is easy to change.
- */
-function theme_l10n_community_button($type, $class, $extras = '') {
-  switch ($type) {
-    case 'translate':
-      $text = t('Translate');
-      break;
-    case 'lookup':
-      $text = t('Information');
-      break;
-    case 'edit':
-      // Source string and translation edit field.
-      $text = t('Edit');
-      break;
-    case 'has-suggestion':
-    case 'has-no-suggestion':
-    case 'untranslated':
-      // Star in a filled circle.
-      $text = t('Suggestions');
-      break;
-    case 'approve':
-      // Checkmark.
-      $text = t('Approve');
-      break;
-    case 'decline':
-      // Checkmark.
-      $text = t('Decline');
-      break;
-    case 'save':
-      // Save button.
-      $text = t('Save');
-      break;
-    case 'clear':
-      // Clear form button.
-      $text = t('Clear');
-      break;
-  }
-  return ' <span title="'. $text .'" class="'. $class .' l10n-button"'. (!empty($extras) ? (' '. $extras) : '') .'><b><b>'. $text .'</b></b></span>';
-}
-
-/**
- * Theme a list of translatable strings. Adds a copy button to each string
- * for quickly copying its source text into a translation form.
- */
-function theme_l10n_community_strings($items, $form = TRUE) {
-  $output = "<ul class='l10n-community-strings'>";
-  foreach ($items as $i => $item) {
-    $output .= "<li class='clear-block'>";
-    if ($form && ($i < 1)) {
-      // Only print copy button if we are displaying a form and on the first item.
-      // For plurals, only the first item will have the copy button, but will copy
-      // all the values into the form.
-      $output .= "<div class='buttons'>". theme('l10n_community_copy_button') ."</div>";
-    }
-    $output .= "<div class='string'>". $item ."</div>";
-    $output .= "</li>";
-  }
-  $output .= "</ul>";
-  return $output;
-}
-
-/**
- * Copy button for string values.
- */
-function theme_l10n_community_copy_button() {
-  return theme('l10n_community_button', 'edit', 'l10n-community-copy');
-}
-
-/**
  * Format string for display. Takes plurals into account.
  */
 function l10n_community_format_string($value, $rich_markup = TRUE) {
diff --git l10n_community/moderate.inc l10n_community/moderate.inc
index 1e94a5e..04fb316 100644
--- l10n_community/moderate.inc
+++ l10n_community/moderate.inc
@@ -23,7 +23,7 @@ function l10n_community_moderate_page($langcode) {
   $filters = l10n_community_build_filter_values($_GET, TRUE);
 
   $output = drupal_get_form('l10n_community_filter_form', $filters, TRUE);
-  
+
   $strings = l10n_community_get_suggestions($langcode, $filters);
   if (!count($strings)) {
     drupal_set_message(t('No strings found with this filter. Try adjusting the filter options.'));
@@ -33,12 +33,12 @@ function l10n_community_moderate_page($langcode) {
     drupal_set_title(t('Suggestions for @language', array('@language' => $languages[$langcode]->name)));
     $output .= drupal_get_form('l10n_community_moderation_form', $strings, $languages[$langcode], $filters);
   }
-  return $output;  
+  return $output;
 }
 
 // == Moderation form ==========================================================
 
-/** 
+/**
  * Translation web interface.
  *
  * @param $strings
@@ -51,7 +51,7 @@ function l10n_community_moderate_page($langcode) {
 function l10n_community_moderation_form(&$form_state, $strings = array(), $language = NULL, $filters) {
 
   $form['#redirect'] = array($_GET['q'], l10n_community_flat_filters($filters));
-  
+
   $form['pager'] = array(
     '#value' => theme('pager', NULL, $filters['limit'], 0)
   );
@@ -97,7 +97,7 @@ function l10n_community_moderation_form(&$form_state, $strings = array(), $langu
     $form['strings']['translation'][$string->tid] = array(
       //'#type' => 'item',
       '#value' => l10n_community_format_string($string->translation, FALSE),
-    );    
+    );
     $form['strings']['suggestion'][$string->tid] = array(
       //'#type' => 'item',
       '#value' => l10n_community_format_string($string->suggestion, FALSE),
@@ -156,13 +156,13 @@ function l10n_community_get_suggestions($langcode, $filters) {
   $join[] = "INNER JOIN {l10n_community_string} s ON ts.sid = s.sid";
   $where[] = "ts.is_suggestion = 1 AND ts.is_active = 1 AND ts.language = '%s'";
   $where_args[] = $langcode;
-    
+
   // Add submitted by condition
   if (!empty($filters['author'])) {
     $where[] = "ts.uid_entered = %d";
     $where_args[] = $filters['author']->uid;
   }
-  
+
   // Restrict based on project or release.
   $release = empty($filters['release']) || $filters['release'] === 'all' ? NULL : $filters['release'];
   $project = $filters['project'];
@@ -173,7 +173,7 @@ function l10n_community_get_suggestions($langcode, $filters) {
       // Release restriction.
       $where_args[] = $release;
       $where[] = 'l.rid = %d';
-    }  
+    }
     elseif ($project) {
       $where[] = "l.pid = %d";
       $where_args[] = $project->pid;
@@ -208,7 +208,7 @@ function l10n_community_get_suggestions($langcode, $filters) {
     $join[] = "LEFT JOIN {l10n_community_translation} tt ON tt.sid = ts.sid AND tt.is_suggestion = 0 AND tt.is_active = 1 AND tt.language = '%s'";
     $join_args[] = $langcode;
   }
-  
+
   // Only show strings matching the user's permissions. It should be already
   // ensured that the user has either one of these permissions, when calling
   // this function, or nothing will be returned.
@@ -241,7 +241,7 @@ function l10n_community_get_suggestions($langcode, $filters) {
  */
 function l10n_community_moderate_approve_selected($langcode, $tids, $strings) {
   global $user;
-  
+
   // In case we have more than one sugggestion for an sid, error.
   $sids = array_unique($strings);
   if (count($sids) < count($strings)) {
@@ -250,15 +250,15 @@ function l10n_community_moderate_approve_selected($langcode, $tids, $strings) {
   }
   $tid_placeholders = db_placeholders($tids);
   $sid_placeholders = db_placeholders($sids);
-  
+
   // Mark existing translations and suggestions as inactive in this language.
   $args = array_merge($sids, array($langcode));
   db_query("UPDATE {l10n_community_translation} SET is_active = 0 WHERE sid IN ($sid_placeholders) AND language = '%s'", $args);
-  
+
   // Remove placeholder translation record (which was there if
   // first came suggestions, before an actual translation).
   db_query("DELETE FROM {l10n_community_translation} WHERE sid IN ($sid_placeholders) AND translation = '' AND language = '%s'", $args);
-  
+
   // Mark this exact suggestions as active, and set approval time.
   $args = array_merge(array(time(), $user->uid), $tids);
   db_query("UPDATE {l10n_community_translation} SET time_approved = %d, uid_approved = %d, has_suggestion = 0, is_suggestion = 0, is_active = 1 WHERE tid IN ($tid_placeholders)", $args);
@@ -278,15 +278,15 @@ function l10n_community_moderate_decline_selected($langcode, $tids, $sids) {
   // Deactive the selected suggestions.
   db_query("UPDATE {l10n_community_translation} SET is_active = 0 WHERE tid IN ($tid_placeholders)", $tids);
   drupal_set_message(format_plural(db_affected_rows(), 'A suggestion has been declined.', '@count suggestions have been declined.'));
-  
+
   // Update 'has suggestion' option for remaining string translations
   $args = array_merge(array($langcode), $sids);
   $result = db_query(
-    "SELECT tt.tid 
-     FROM {l10n_community_translation} tt 
-     LEFT JOIN {l10n_community_translation} ts ON tt.sid = ts.sid AND tt.language = ts.language AND ts.is_active = 1 AND ts.is_suggestion = 1 
-     WHERE tt.is_active = 1 AND tt.is_suggestion = 0 AND tt.has_suggestion = 1 AND tt.language = '%s' AND tt.sid IN ($sid_placeholders) 
-     GROUP BY tt.tid 
+    "SELECT tt.tid
+     FROM {l10n_community_translation} tt
+     LEFT JOIN {l10n_community_translation} ts ON tt.sid = ts.sid AND tt.language = ts.language AND ts.is_active = 1 AND ts.is_suggestion = 1
+     WHERE tt.is_active = 1 AND tt.is_suggestion = 0 AND tt.has_suggestion = 1 AND tt.language = '%s' AND tt.sid IN ($sid_placeholders)
+     GROUP BY tt.tid
      HAVING COUNT(ts.tid) = 0",
     $args
   );
@@ -304,7 +304,7 @@ function theme_l10n_community_moderation_form($form) {
   $output = '';
   $pager = isset($form['pager']) ? drupal_render($form['pager']) : '';
   $output .= $pager;
-  $output .= drupal_render($form['options']);  
+  $output .= drupal_render($form['options']);
   $header = array(theme('table_select_header_cell'), t('Suggestion'), t('Translation'), t('Source'));
   foreach (element_children($form['strings']['tid']) as $tid) {
     $rows[] = array(
diff --git l10n_community/translate.inc l10n_community/translate.inc
index 8d07552..b099c2d 100644
--- l10n_community/translate.inc
+++ l10n_community/translate.inc
@@ -6,50 +6,7 @@
  *   Translation view and editing pages for localization community.
  */
 
-// = Translation interface hub =================================================
-
-/**
- * Menu callback for the translation pages.
- *
- * Displays a translation view or translation edit page depending
- * on permissions. If no strings are found, an error is printed.
- *
- * @param $langcode
- *   Language code, for example 'hu', 'pt-br', 'de', 'it'.
- */
-function l10n_community_translate_page($langcode = NULL, $mode = 'view') {
-
-  // Add missing breadcrumb.
-  drupal_set_breadcrumb(
-    array(
-      l(t('Home'), NULL),
-      l(t('Translate'), 'translate')
-    )
-  );
-
-  $languages = l10n_community_get_languages();
-  $perm = l10n_community_get_permission($langcode);
-
-  $filters = l10n_community_build_filter_values($_GET);
-  $output = drupal_get_form('l10n_community_filter_form', $filters);
-
-  $strings = l10n_community_get_strings($languages[$langcode]->language, $filters, $filters['limit']);
-  if (!count($strings)) {
-    drupal_set_message(t('No strings found with this filter. Try adjusting the filter options.'));
-  }
-  elseif (!($perm & L10N_PERM_SUGGEST) || $mode == 'view') {
-    // For users without permission to translate or suggest, display the view.
-    drupal_set_title(t('@language translations', array('@language' => $languages[$langcode]->name)));
-    $output .= l10n_community_translate_view($strings, $languages[$langcode], $filters);
-  }
-  else {
-    // For users with some permission, display the form.
-    drupal_add_js(drupal_get_path('module', 'l10n_community') .'/l10n_community.js');
-    drupal_set_title(t('Translate to @language', array('@language' => $languages[$langcode]->name)));
-    $output .= drupal_get_form('l10n_community_translate_form', $strings, $languages[$langcode], $filters, $perm);
-  }
-  return $output;
-}
+module_load_include('inc', 'l10n_community', 'api');
 
 // = Filter form handling ======================================================
 
@@ -74,7 +31,7 @@ function l10n_community_filter_form(&$form_state, $filters, $limited = FALSE) {
     L10N_STATUS_NO_SUGGESTION  => t('Has no suggestion'),
     L10N_STATUS_HAS_SUGGESTION => t('Has suggestion'),
   );
-  
+
   $form['project'] = array(
     '#title' => t('Project'),
     '#default_value' => isset($filters['project']) ? $filters['project']->title : '',
@@ -190,386 +147,431 @@ function l10n_community_filter_form_submit($form, &$form_state) {
   }
 }
 
-// = Translation viewer ========================================================
-
 /**
- * Form for translations display.
- *
- * @param $strings
- *   Array of string objects to display on the page.
- * @param $language
- *   Language object corresponding to the page displayed.
- * @param $filters
- *   Filters used to present this listing view.
+ * Theme function for l10n_community_filter_form.
  */
-function l10n_community_translate_view($strings = array(), $language = NULL, $filters = array()) {
-  $output = '';
-  $rows = array();
-  foreach ($strings as $string) {
-    $row = array();
-    // Source display
-    $source = l10n_community_format_string($string->value);
-    $source .= theme('l10n_community_in_context', $string);
-    $row[] = array('data' => $source, 'class' => 'source');
-
-    // Translation display.
-    if (!empty($string->translation)) {
-      if (strpos($string->value, chr(0)) !== FALSE) {
-        $translations = explode(chr(0), l10n_community_format_text($string->translation));
-        // Fill in any missing items, so it is shown that not all items are done.
-        if (count($translations) < $language->plurals) {
-          $translations = array_merge($translations, array_fill(0, count($translations) - $language->plurals, ''));
-        }
-        $translation = theme('item_list', $translations);
-      }
-      else {
-        $translation = l10n_community_format_text($string->translation);
-      }
-      $row[] = $translation;
-    }
-    else {
-      $row[] = '';
+function theme_l10n_community_filter_form($form) {
+  $row = array();
+  $labels = array();
+  // Only display these elements in distinct table cells
+  $elements = array('project', 'release', 'context', 'status', 'author', 'search', 'limit');
+  foreach ($form as $id => &$element) {
+    if (in_array($id, $elements)) {
+      $labels[] = $element['#title'];
+      unset($element['#title']);
+      $row[] = drupal_render($element);
     }
-    $rows[] = $row;
   }
-  $output .= ($pager = theme('pager', NULL, $filters['limit'], 0));
-  $output .= theme('table', array(t('Source Text'), t('Translations')), $rows, array('class' => 'l10n-server-translate'));
-  $output .= $pager;
-  $output = "<div id='l10n-community-translate-view'>". $output ."</div>";
-  return $output;
+  // Fill in the rest of the header above the buttons.
+  $labels[] = '';
+  // Display the rest of the form in the last cell
+  $row[] = array('data' => drupal_render($form), 'class' => 'last');
+  return theme('table', $labels, array($row), array('class' => 'l10n-server-filter'));
 }
 
-// = Translation editor ========================================================
+// = Translation view ==========================================================
 
 /**
- * Translation web interface.
- *
- * @param $strings
- *   Array of string objects to display.
- * @param $language
- *   Language object.
- * @param $filters
- *   Filters used to present this editing view.
- * @param $perm
- *   Community permission level of user watching the page.
+ * Menu callback: List translations and suggestions
  */
-function l10n_community_translate_form(&$form_state, $strings = array(), $language = NULL, $filters = array(), $perm = L10N_PERM_SUGGEST) {
-
-  if (isset($_GET['page'])) {
-    // Ensure that we keep all filter values, even the page number, so
-    // after submission, the same page can be shown.
-    $filters['page'] = (int) $_GET['page'];
-  }
+function l10n_community_translate_page($langcode) {
+  drupal_add_css(drupal_get_path('module', 'l10n_community') .'/editor.css');
+  drupal_add_js(drupal_get_path('module', 'l10n_community') .'/jquery.worddiff.js');
+  drupal_add_js(drupal_get_path('module', 'l10n_community') .'/editor.js');
 
-  $form = array(
-    '#tree' => TRUE,
-    '#redirect' => array($_GET['q'], l10n_community_flat_filters($filters))
-  );
-  $form['pager_top'] = array(
-    '#weight' => -10,
-    '#value' => ($pager = theme('pager', NULL, $filters['limit'], 0)),
-  );
-  $form['pager_bottom'] = array(
-    '#weight' => 10,
-    '#value' => $pager,
-  );
-  // Keep language code and URI in form for further reference.
-  $form['langcode'] = array(
-    '#type' => 'value',
-    '#value' => $language->language
-  );
-  $form['project'] = array(
-    '#type' => 'value',
-    '#value' => isset($project) ? $project->uri : NULL
-  );
+  $language = l10n_community_get_language($langcode);
+  $filters = l10n_community_build_filter_values($_GET);
+  $strings = l10n_community_get_strings($language->language, $filters, $filters['limit']);
 
-  foreach ($strings as $string) {
-    $form[$string->sid] = array(
-      '#tree' => TRUE,
-    );
+  // Set the most appropriate title.
+  if ($filters['project']) {
+    drupal_set_title(t('Translate %project to @language', array('%project' => $filters['project']->title, '@language' => $language->name)));
+  }
+  else {
+    drupal_set_title(t('Translate to @language', array('@language' => $language->name)));
+  }
 
-    // A toolbox which displays action icons on each string editor fieldset.
-    $toolbox = theme('l10n_community_button', 'translate', 'l10n-translate active');
-    $toolbox .= theme('l10n_community_button', 'lookup', 'l10n-lookup');
-    $toolbox .= $string->has_suggestion ? theme('l10n_community_button', 'has-suggestion', 'l10n-suggestions') : "";
-    $toolbox = "<div class='toolbox'>$toolbox</div>";
-    $form[$string->sid]['toolbox'] = array(
-      '#type' => 'markup',
-      '#value' => $toolbox,
-    );
-    $form[$string->sid]['messagebox'] = array(
-      '#type' => 'markup',
-      '#value' => "<div class='messagebox'></div>",
-    );
+  // Add the filter form.
+  $output = drupal_get_form('l10n_community_filter_form', $filters);
 
-    $is_plural = strpos($string->value, "\0");
-    // Multiple source strings if we deal with plurals. The form item and
-    // consequently the JavaScript strings identifiers are the sid and then
-    // the index of the plural being displayed.
-    $string_parts = explode(chr(0), $string->value);
-    foreach ($string_parts as $delta => &$part) {
-      $part = l10n_community_format_text($part, $string->sid, (count($string_parts) > 1) ? $delta : NULL);
-    }
-    $source = theme('l10n_community_strings', $string_parts);
-    $source .= theme('l10n_community_in_context', $string);
+  // Output the actual strings.
+  if (!count($strings)) {
+    drupal_set_message(t('No strings found with this filter. Try adjusting the filter options.'));
+  }
+  else {
+    $output .= drupal_get_form('l10n_community_translate_form', $language, $filters, $strings);
+  }
 
-    $form[$string->sid]['source'] = array(
-      '#type' => 'item',
-      '#value' => $source,
-    );
+  return $output;
+}
 
-    $translated = !empty($string->translation);
-    $form[$string->sid]['translation'] = array(
-      '#type' => 'item',
-      // Hide editing controls of translated stuff to save some space and guide user eyes.
-      '#prefix' => '<div id="l10n-community-wrapper-'. $string->sid .'"'. ($translated ? ' class="hidden"' : '') .'>',
-      '#suffix' => '</div>',
-    );
+/**
+ * Form callback: List translations and suggestions.
+ *
+ * @param $form_state
+ *   The form state array.
+ * @param $language
+ *   A language object.
+ * @param $filters
+ *   An array of filters applied to the strings.
+ * @param $strings
+ *   The strings to render.
+ */
+function l10n_community_translate_form(&$form_state, $language, $filters, $strings) {
+  $permission = l10n_community_get_permission($language->language);
+  $pager = theme('pager', NULL, $filters['limit'], 0);
+  $redirect_url = $_GET;
+  unset($redirect_url['q']);
 
-    if ($is_plural) {
+  $form = array(
+    '#submit' => array('l10n_community_translate_submit'),
+    '#redirect' => array($_GET['q'], $redirect_url),
+    'langcode' => array('#type' => 'value', '#value' => $language->language),
+    'pager_top' => array('#weight' => -10, '#value' => $pager),
+    'submit_top' => array('#type' => 'submit', '#value' => t('Save changes'), '#access' => $permission & L10N_PERM_SUGGEST),
+    'strings' => array('#tree' => TRUE, '#theme' => 'l10n_community_translate_table'),
+    'submit' => array('#type' => 'submit', '#value' => t('Save changes'), '#access' => $permission & L10N_PERM_SUGGEST),
+    'pager_bottom' => array('#weight' => 10, '#value' => $pager),
+  );
 
-      // Dealing with a string with plural versions.
-      if ($translated) {
-        // Add translation form element with all plural versions.
-        $translations = explode("\0", $string->translation);
-        $string_parts = array();
-        for ($i = 0; $i < $language->plurals; $i++) {
-          $target = $string->sid .'-'. $i;
-          $string_parts[] = l10n_community_format_text($translations[$i], $string->sid, $i);
-        }
-        $form[$string->sid]['translation_existing'] = array(
-          '#type' => 'item',
-          '#value' => theme('l10n_community_strings', $string_parts),
-        );
-      }
+  foreach ($strings as $string) {
+    $form['strings'][$string->sid] = _l10n_community_translate_string($form_state, $string, $language, $permission);
+  }
 
-      $string_parts = explode(chr(0), $string->value);
+  return $form;
+}
 
-      for ($i = 0; $i < $language->plurals; $i++) {
-        $target = $string->sid .'-'. $i;
-        if ($translated) {
-          // Already translated so we ask for new translation or suggestion.
-          $description = !($perm & L10N_PERM_MODERATE_OWN) ? t('New suggestion for variant #%d', array('%d' => $i)) : t('New translation for variant #%d', array('%d' => $i));
-        }
-        else {
-          // Not translated yet, so we ask for initial translation or suggestion.
-          $description = !($perm & L10N_PERM_MODERATE_OWN) ? t('Suggestion for variant #%d', array('%d' => $i)) : t('Translation for variant #%d', array('%d' => $i));
-        }
+/**
+ * Return a marked-up string.
+ */
+function _l10n_community_translate_render_strings($strings, $empty = '') {
+  if ($empty) {
+    $empty = ' data-empty="'. check_plain($empty) .'"';
+  }
+  return "<span$empty>". implode("</span><br /><span$empty>", array_map('check_plain', $strings)) .'</span>';
+}
 
-        // Include editing area for each plural variant.
-        $source_index = ($i > 0 ? 1 : 0);
-        $form[$string->sid]['translation']['value'][$i] = array(
-          // Use textarea for long and multiline strings.
-          '#type' => ((strlen($string_parts[$source_index]) > 45) || (count(explode("\n", $string_parts[$source_index])) > 1)) ? 'textarea' : 'textfield',
-          '#description' => $description,
-          '#rows' => 1,
-          '#id' => 'l10n-community-translation-'. $target,
-        );
-      }
-    }
+/**
+ * Creates the form fragment for a source string.
+ */
+function _l10n_community_translate_string(&$form_state, $source, $language, $permission) {
+  // Normalize empty default translation.
+  if (!$source->translation) {
+    $source->tid = '0';
+    $source->translation = array(t('(not translated)'));
+    $source->is_active = '1';
+    $source->is_suggestion = '0';
+  }
+  else {
+    $source->translation = l10n_community_unpack_string($source->translation);
+  }
 
-    // Dealing with a simple string (no plurals).
+  $source->value = l10n_community_unpack_string($source->value);
 
-    else {
-      if ($translated) {
-        $form[$string->sid]['translation_existing'] = array(
-          '#type' => 'item',
-          '#value' => theme('l10n_community_strings', array(l10n_community_format_text($string->translation, $string->sid))),
-        );
-      }
-      $form[$string->sid]['translation']['value'] = array(
-        // Use textarea for long and multiline strings.
-        '#type' => ((strlen($string->value) > 45) || (count(explode("\n", $string->value)) > 1)) ? 'textarea' : 'textfield',
-        // Provide accurate title based on previous data and permission.
-        '#description' => $translated ? (!($perm & L10N_PERM_MODERATE_OWN) ? t('Add a new suggestion') : t('Add a new translation')) : (!($perm & L10N_PERM_MODERATE_OWN) ? t('Suggestion') : ''),
-        '#rows' => 4,
-        '#resizable' => FALSE,
-        '#cols' => NULL,
-        '#size' => NULL,
-        '#id' => 'l10n-community-translation-'. $string->sid,
-      );
-      if (strlen($string->value) > 200) {
-        $form[$string->sid]['translation']['value']['#rows'] = floor(strlen($string->value) * .03);
-        $form[$string->sid]['translation']['value']['#resizable'] = TRUE;
-      }
-    }
+  $form = array(
+    '#string' => $source,
+    '#langcode' => $language->language,
+    'source' => array(
+      'string' => array('#value' => _l10n_community_translate_render_strings($source->value)),
+    ),
+  );
 
-    // Add AJAX saving buttons
-    $form[$string->sid]['translation']['save'] = array(
-      '#prefix' => "<span class='l10n-approval-buttons'>",
-      '#value' => theme('l10n_community_button', 'save', 'l10n-save'),
-      '#type' => 'markup',
-    );
-    $form[$string->sid]['translation']['clear'] = array(
-      '#suffix' => "</span>",
-      '#value' => theme('l10n_community_button', 'clear', 'l10n-clear'),
-      '#type' => 'markup',
+  if ($permission & L10N_PERM_SUGGEST) {
+    $form['source']['edit'] = array(
+      '#value' => t('Edit Copy'),
+      '#prefix' => '<label title="'. t('Edit Copy') .'">',
+      '#suffix' => '</label>',
     );
+  }
 
-    if (!($perm & L10N_PERM_MODERATE_OWN)) {
-      // User with suggestion capability only, record this.
-      $form[$string->sid]['translation']['is_suggestion'] = array(
-        '#type' => 'value',
-        '#value' => TRUE
-      );
-    }
-    else {
-      // User with full privileges, offer option to submit suggestion.
-      $form[$string->sid]['translation']['is_suggestion'] = array(
-        '#title' => t('Suggestion for discussion'),
-        '#type' => 'checkbox',
-      );
+  // Add the current string (either a approved translation or a mock object
+  // for the "untranslated" string).
+  $form[$source->tid] = _l10n_community_translate_translation($form_state, $source, $permission, $source);
+
+  // When there are suggestions, load them from the database.
+  if ($source->has_suggestion) {
+    $result = db_query("SELECT t.tid, t.sid, t.translation, t.uid_entered, t.time_entered, t.is_active, t.is_suggestion, u.name as username FROM {l10n_community_translation} t LEFT JOIN {users} u ON u.uid = t.uid_entered WHERE t.language = '%s' AND t.sid = %d AND t.is_active = 1 AND t.is_suggestion = 1 ORDER BY t.time_entered", $language->language, $source->sid);
+    while ($suggestion = db_fetch_object($result)) {
+      $suggestion->translation = l10n_community_unpack_string($suggestion->translation);
+      // Add the suggestion to the list.
+      $form[$suggestion->tid] = _l10n_community_translate_translation($form_state, $suggestion, $permission, $source);
     }
   }
 
-  // Add all strings for copy-pasting and some helpers.
-  drupal_add_js(
-    array(
-      'l10n_lookup_help'      => t('Show detailed information.'),
-      'l10n_approve_error'    => t('There was an error approving this suggestion. You might not have permission or the suggestion id was invalid.'),
-      'l10n_approve_confirm'  => t('!icon Suggestion approved.', array('!icon' => '&#10004;')),
+  // If the user may add new suggestions, display a textarea.
+  if ($permission & L10N_PERM_SUGGEST) {
+    $textarea = _l10n_community_translate_translation_textarea($source, $language);
+    $form[$textarea->tid] = _l10n_community_translate_translation($form_state, $textarea, $permission, $source);
+  }
 
-      'l10n_decline_error'    => t('There was an error declining this suggestion. You might not have permission or the suggestion id was invalid.'),
-      'l10n_decline_confirm'  => t('Suggestion declined.'),
+  return $form;
+}
 
-      'l10n_details_callback' => url('translate/details/'. $language->language .'/'),
-      'l10n_suggestions_callback' => url('translate/suggestions/'. $language->language .'/'),
-      'l10n_approve_callback' => url('translate/approve/'),
-      'l10n_decline_callback' => url('translate/decline/'),
-      'l10n_form_token_path'  => variable_get('clean_url', '0') ? '?form_token=' : '&form_token=',
-      'l10n_num_plurals'      => $language->plurals
-    ),
-    'setting'
-  );
+/**
+ * Build mock object for new textarea.
+ */
+function _l10n_community_translate_translation_textarea($source, $language) {
+  global $user;
 
-  // Let the user submit the form.
-  $form['submit'] = array(
-    '#type' => 'submit',
-    '#value' => !($perm & L10N_PERM_MODERATE_OWN) ? t('Save suggestions') : t('Save translations')
+  return (object)array(
+    'sid' => $source->sid,
+    'tid' => 'new',
+    'translation' => array_fill(0, count($source->value), ''),
+    'is_active' => '1',
+    'is_suggestion' => '1',
+    'uid_entered' => $user->uid,
   );
+}
 
-  $form['#theme'] = 'l10n_community_translate_form';
+/**
+ * Generates the byline containing meta information about a string.
+ */
+function l10n_community_translate_byline($string) {
+  $params = array(
+    '!author' => theme('username', (object)array('name' => $string->username, 'uid' => $string->uid_entered)),
+    '@date' => format_date($string->time_entered),
+    '@ago' => t('@time ago', array('@time' => format_interval(time() - $string->time_entered))),
+  );
 
-  return $form;
+  if (!empty($string->uid_approved) && $string->uid_entered === $string->uid_approved && $string->time_entered === $string->time_approved) {
+    return t('translated and approved by !author <span title="@ago">on @date</span>', $params);
+  }
+  else {
+    $title = t('suggested by !author <span title="@ago">on @date</span>', $params);
+    if (!empty($string->uid_approved)) {
+      $title .= '<br />'. t('approved by !author <span title="@ago">on @date</span>', array(
+        '!author' => theme('username', (object)array('name' => $string->username_approved, 'uid' => $string->uid_approved)),
+        '@date' => format_date($string->time_approved),
+        '@ago' => t('@time ago', array('@time' => format_interval(time() - $string->time_approved))),
+      ));
+    }
+    return $title;
+  }
 }
 
 /**
- * Save translations entered in the web form.
+ * Creates the form fragment for a translated string.
  */
-function l10n_community_translate_form_submit($form, &$form_state) {
+function _l10n_community_translate_translation(&$form_state, $string, $permission, $source) {
   global $user;
+  $is_own = $user->uid == $string->uid_entered;
+  $is_active = $string->is_active && !$string->is_suggestion;
+  $is_new = $string->tid == 'new';
+  $may_moderate = $permission & ($is_own ? L10N_PERM_MODERATE_OWN : L10N_PERM_MODERATE_OTHERS);
+  //$may_stabilize = $permission & ($is_own ? L10N_PERM_STABILIZE_OWN : L10N_PERM_STABILIZE_OTHERS);
 
-  $inserted = $updated = $unchanged = $suggested = $duplicates = $ignored = 0;
+  $form = array(
+    '#theme' => 'l10n_community_translate_translation',
+    'original' => array('#type' => 'value', '#value' => $string),
+  );
 
-  foreach ($form_state['values'] as $sid => $item) {
-    if (!is_array($item) || !isset($item['translation'])) {
-      // Skip, if we don't have translations in this form item,
-      // which means this is some other form value.
-      continue;
-    }
+  $form['active'] = array(
+    '#type' => 'radio',
+    '#theme' => 'l10n_community_translate_radio',
+    '#title' => _l10n_community_translate_render_strings($string->translation, $is_new ? t('(empty)') : FALSE),
+    '#return_value' => $string->tid,
+    '#default_value' => $is_active ? $string->tid : NULL,
+    '#parents' => array('strings', $string->sid, 'active'),
+    '#disabled' => !$may_moderate && !$is_active,
+    '#attributes' => array('class' => 'selector'),
+  );
 
-    $source_string = db_result(db_query('SELECT value FROM {l10n_community_string} WHERE sid = %d', $sid));
-    $text = '';
-    if (is_string($item['translation']['value']) && strlen(trim($item['translation']['value']))) {
-      // Single string representation: simple translation.
-      $text = l10n_community_trim($item['translation']['value'], $source_string);
+  if ($string->tid) {
+    if ($may_moderate && $string->tid != 'new') {
+      $form['declined'] = array(
+        '#type' => 'checkbox',
+        '#title' => t('Declined'),
+        '#default_value' => !($string->is_active || $string->is_suggestion),
+      );
     }
-    if (is_array($item['translation']['value'])) {
-      // Array -> plural variants are provided. Join them with a NULL separator.
-      $text = join("\0", $item['translation']['value']);
-      if (trim($text) == '') {
-        // If the whole string only contains NULL bytes, empty the string, so
-        // we don't save an empty translation. Otherwise the NULL bytes need
-        // to be there, so we know plural variant indices.
-        $text = '';
-      }
+    // if ($may_stabilize) {
+    //   $form['stable'] = array(
+    //     '#type' => 'checkbox',
+    //     '#title' => t('Stable'),
+    //     '#default_value' => FALSE, // $string->is_stable,
+    //   );
+    // }
+    if ($string->tid == 'new') {
+      $form['value'] = array_fill(0, count($source->value), array(
+        '#type' => 'textarea',
+        '#cols' => 60,
+        '#rows' => 3,
+        '#default_value' => t('<New translation>'),
+      ));
     }
+    else {
+      if ($permission & L10N_PERM_SUGGEST) {
+        $form['edit'] = array(
+          '#value' => t('Edit Copy'),
+          '#prefix' => '<label title="'. t('Edit Copy') .'">',
+          '#suffix' => '</label>',
+        );
+      }
+      if (isset($string->username)) {
+        $title = l10n_community_translate_byline($string);
 
-    if (!empty($text)) {
-      // Check for duplicate translation or suggestion.
-      if (l10n_community_is_duplicate($text, $sid, $form_state['values']['langcode'])) {
-        $duplicates++;
-        continue;
+        $form['author'] = array(
+          '#value' => $title,
+        );
       }
+    }
+  }
 
-      // We have some string to save.
-      l10n_community_target_save(
-        $sid, $text, $form_state['values']['langcode'], $user->uid,
-        ($item['translation']['is_suggestion'] == TRUE),
-        $inserted, $updated, $unchanged, $suggested
-      );
+  return $form;
+}
+
+function theme_l10n_community_translate_actions($element) {
+  $actions = '';
+  foreach (array('declined', /*'stable', */'edit') as $type) {
+    if (isset($element[$type])) {
+      $actions .= '<li class="'. $type .'">'. drupal_render($element[$type]) .'</li>';
     }
   }
+  if (!empty($actions)) {
+    return '<ul class="actions">'. $actions .'</ul>';
+  }
+  else {
+    return '';
+  }
+}
+
+function theme_l10n_community_translate_translation($element) {
+  if (!isset($element['#attributes']['class'])) {
+    $element['#attributes']['class'] = '';
+  }
+  $element['#attributes']['class'] .= ' clearfix translation';
+
+  switch ($element['active']['#return_value']) {
+    case 'new':
+      $element['#attributes']['class'] .= ' new-translation';
+      break;
+    case '0':
+      $element['#attributes']['class'] .= ' no-translation';
+      // Fallthrough.
+    default:
+      if ($element['active']['#value'] !== '') {
+        $element['#attributes']['class'] .= ' is-active default';
+      }
+  }
+
+  $output = '<li'. drupal_attributes($element['#attributes']) .'>';
+  $output .= theme('l10n_community_translate_actions', $element);
+  $output .= drupal_render($element['active']);
+
+  if (isset($element['author'])) {
+    $output .= '<div class="author">'. drupal_render($element['author']) .'</div>';
+  }
+
+  if (isset($element['value'])) {
+    $output .= drupal_render($element['value']);
+  }
 
-  // Inform user about changes made to the database.
-  l10n_community_update_message($inserted, $updated, $unchanged, $suggested, $duplicates, $ignored);
+  return $output  . '</li>';
 }
 
-// = Theme functions ===========================================================
+function theme_l10n_community_translate_radio($element) {
+  _form_set_class($element, array('form-radio'));
+  $output = '<input type="radio" ';
+  $output .= 'id="'. $element['#id'] .'" ';
+  $output .= 'name="'. $element['#name'] .'" ';
+  $output .= 'value="'. $element['#return_value'] .'" ';
+  $output .= (check_plain($element['#value']) == $element['#return_value']) ? ' checked="checked" ' : ' ';
+  $output .= drupal_attributes($element['#attributes']) .' />';
+
+  if (isset($element['#title'])) {
+    $output .= '<label class="l10n-string" for="'. $element['#id'] .'">'. $element['#title'] .'</label>';
+  }
 
-/**
- * Theme function for l10n_community_filter_form.
- */
-function theme_l10n_community_filter_form($form) {
-  $row = array();
-  $labels = array();
-  // Only display these elements in distinct table cells
-  $elements = array('project', 'release', 'context', 'status', 'author', 'search', 'limit');
-  foreach ($form as $id => &$element) {
-    if (in_array($id, $elements)) {
-      $labels[] = $element['#title'];
-      unset($element['#title']);
-      $row[] = drupal_render($element);
+  return $output;
+}
+
+function theme_l10n_community_translate_translation_list($element) {
+  $output = '<ul>';
+  foreach (element_children($element) as $child) {
+    if (is_numeric($child) || $child == 'new') {
+      $output .= drupal_render($element[$child]);
     }
   }
-  // Fill in the rest of the header above the buttons.
-  $labels[] = '';
-  // Display the rest of the form in the last cell
-  $row[] = array('data' => drupal_render($form), 'class' => 'last');
-  return theme('table', $labels, array($row), array('class' => 'l10n-server-filter'));
+  $output .= '</ul>';
+
+  return $output;
 }
 
-/**
- * Theme function for l10n_community_translate_form.
- */
-function theme_l10n_community_translate_form($form) {
+function theme_l10n_community_translate_source($element) {
+  $output = theme('l10n_community_translate_actions', $element['source']);
+  $output .= '<label class="l10n-string">'. drupal_render($element['source']['string']) .'</label>';
+  $output .= theme('l10n_community_in_context', $element['#string']);
+  $output .= '<div class="l10n-usage"><a href="'. url('translate/details/'. $element['#langcode'] .'/'. $element['#string']->sid) .'" class="l10n-more">'. t('More information') .'</a></div>';
+  return $output;
+}
+
+function theme_l10n_community_translate_table($element) {
+  $header = array(
+    t('Source Text'),
+    t('Translations'),
+  );
+
   $rows = array();
-  $output = '';
+  foreach (element_children($element) as $key) {
+    $rows[] = array(
+      array('class' => 'source', 'data' => theme('l10n_community_translate_source', $element[$key])),
+      array('class' => 'translation', 'data' => theme('l10n_community_translate_translation_list', $element[$key])),
+    );
+  }
 
-  foreach ($form as $id => &$element) {
-    // if the form id is numeric, this form element is for editing a string
-    if (is_numeric($id)) {
-      $source_pane = drupal_render($element['source']);
-      $translation_pane = "<div class='l10n-panes clear-block'>";
-      $translation_pane .= drupal_render($element['toolbox']);
-      $translation_pane .= "<div class='pane translate'>";
-      $translation_pane .= !empty($element['translation_existing']) ? drupal_render($element['translation_existing']) : '';
-      $translation_pane .= drupal_render($element['messagebox']);
-      $translation_pane .= drupal_render($element['translation']) .'</div>';
-      $translation_pane .= "<div class='pane suggestions'></div><div class='pane lookup'></div>";
-      $translation_pane .= "</div>";
-      $row = array(
-        array(
-          'data' => $source_pane,
-          'class' => 'source',
-          'id' => 'spane-'. $id,
-        ),
-        array(
-          'data' => $translation_pane,
-          'class' => 'translation',
-          'id' => 'tpane-'. $id,
-        ),
-      );
-      $rows[] = $row;
-      unset($form[$id]);
+  return theme('table', $header, $rows, array('class' => 'l10n-table'));
+}
+
+function l10n_community_translate_submit($form, &$form_state) {
+  $langcode = $form_state['values']['langcode'];
+
+  foreach ($form_state['values']['strings'] as $sid => $string) {
+    foreach ($string as $tid => $options) {
+      // Store new suggestion.
+      $empty_values = 0;
+      if (isset($options['value']) && is_array($options['value'])) {
+        foreach ($options['value'] as $key => $value) {
+          if ($value === t('<New translation>')) {
+            $options['value'] = '';
+            $empty_values++;
+          }
+        }
+        if ($tid === 'new' && count($options['value']) > $empty_values) {
+          $tid = l10n_community_add_suggestion($langcode, $sid, $options['value']);
+          if ($tid) {
+            l10n_community_counter('added');
+            if ($string['active'] === 'new') {
+              $string['active'] = $tid;
+            }
+          }
+        }
+      }
+
+      if (is_numeric($tid) && $tid > 0) {
+        if ($tid == $string['active']) {
+          if ($options['original']->is_suggestion) {
+            l10n_community_approve_string($langcode, $sid, $tid);
+            l10n_community_counter('approved');
+          }
+          // if (/*!$options['original']->is_stable && */!empty($options['stable'])) {
+          //   dsm('mark as stable');
+          //   l10n_community_counter('stabilized');
+          // }
+        }
+        elseif (!empty($options['declined'])) {
+          // also remove stable flag!
+          l10n_community_counter($options['original']->is_suggestion ? 'suggestion_declined' : 'declined');
+          l10n_community_decline_string($langcode, $sid, $tid);
+        }
+      }
     }
   }
-  $output .= drupal_render($form['pager_top']);
-  $output .= theme('table', array(t('Source Text'), t('Translations')), $rows, array('class' => 'l10n-server-translate'));
-  $output .= drupal_render($form);
-  return $output;
+
+  l10n_community_update_message();
 }
 
+// = Miscellaneous =============================================================
+
 /**
  * Theme context information for source strings.
  *
@@ -615,10 +617,12 @@ function l10n_community_get_strings($langcode, $filters, $pager = NULL) {
   $join = $join_args = $where = $where_args = array();
   $sql = $sql_count = '';
 
-  $select = "SELECT DISTINCT s.sid, s.value, s.context, t.tid, t.language, t.translation, t.uid_entered, t.uid_approved, t.time_entered, t.time_approved, t.has_suggestion, t.is_suggestion, t.is_active FROM {l10n_community_string} s";
+  $select = "SELECT DISTINCT s.sid, s.value, s.context, t.tid, t.language, t.translation, t.uid_entered, t.uid_approved, t.time_entered, t.time_approved, t.has_suggestion, t.is_suggestion, t.is_active, u.name as username, u2.name as username_approved FROM {l10n_community_string} s";
   $select_count = "SELECT COUNT(DISTINCT(s.sid)) FROM {l10n_community_string} s";
   $join[] = "LEFT JOIN {l10n_community_translation} t ON s.sid = t.sid AND t.language = '%s' AND t.is_active = 1 AND t.is_suggestion = 0";
   $join_args[] = $langcode;
+  $join[] = "LEFT JOIN {users} u ON u.uid = t.uid_entered";
+  $join[] = "LEFT JOIN {users} u2 ON u2.uid = t.uid_approved";
 
   // Add submitted by condition
   if (!empty($filters['author'])) {
@@ -636,7 +640,7 @@ function l10n_community_get_strings($langcode, $filters, $pager = NULL) {
       // Release restriction.
       $where_args[] = $release;
       $where[] = 'l.rid = %d';
-    }  
+    }
     elseif ($project) {
       $where[] = "l.pid = %d";
       $where_args[] = $project->pid;
@@ -659,18 +663,18 @@ function l10n_community_get_strings($langcode, $filters, $pager = NULL) {
 
   // Restriction based on string status by translation / suggestions.
   $status_sql = '';
-  if ($filters['status'] & L10N_STATUS_UNTRANSLATED) {
+  if (isset($filters['status']) && $filters['status'] & L10N_STATUS_UNTRANSLATED) {
     // We are doing a LEFT JOIN especially to look into the case, when we have nothing
     // to match in the translation table, but we still have the string. (We get our
     // records in the result set in this case). The translation field is empty or
     // NULL in this case, as we are not allowing NULL there and only saving an empty
     // translation if there are suggestions but no translation yet.
     $where[] = "(t.translation is NULL OR t.translation = '')";
-  } 
+  }
   elseif ($filters['status'] & L10N_STATUS_TRANSLATED) {
     $where[] = "t.translation != ''";
   }
-  if ($filters['status'] & L10N_STATUS_HAS_SUGGESTION) {
+  if (isset($filters['status']) && $filters['status'] & L10N_STATUS_HAS_SUGGESTION) {
     // Note that we are not searching in the suggestions themselfs, only
     // the source and active translation values. The user interface underlines
     // that we are  looking for strings which have suggestions, not the
@@ -732,9 +736,9 @@ function l10n_community_build_filter_values($params, $suggestions = FALSE) {
     'context' => isset($params['context']) ? (string) $params['context'] : 'all',
     'limit' => (isset($params['limit']) && in_array($params['limit'], array(5, 10, 20, 30))) ? (int) $params['limit'] : 10,
   );
-  
-  // The project can be a dropdown or text field depending on number of 
-  // projects. So we need to sanitize its value. 
+
+  // The project can be a dropdown or text field depending on number of
+  // projects. So we need to sanitize its value.
   if (isset($params['project'])) {
     // Try to load project by uri or title, but give URI priority. URI is used
     // to shorten the URL and have simple redirects. Title is used if the
@@ -756,6 +760,152 @@ function l10n_community_build_filter_values($params, $suggestions = FALSE) {
 }
 
 /**
+ * Ensures that there is a mock translation for a given language/string.
+ *
+ * @param $langcode
+ *   The language to be checked for a mock translation.
+ * @param $sid
+ *   The string ID that needs to have a mock translation.
+ */
+function l10n_community_mock_translation($langcode, $sid) {
+  if (!db_result(db_query("SELECT COUNT(*) FROM {l10n_community_translation} WHERE sid = %d AND language = '%s' AND is_suggestion = 0 AND is_active = 1", $sid, $langcode))) {
+    // Insert mock tuple that acts as placeholder.
+    db_query("INSERT INTO {l10n_community_translation} (sid, translation, language, uid_entered, time_entered, is_suggestion, is_active) VALUES (%d, '', '%s', 0, 0, 0, 1)", $sid, $langcode);
+  }
+}
+
+/**
+ * Adds a suggestion to a language/string.
+ *
+ * @param $langcode
+ *   The language of the new translation.
+ * @param $sid
+ *   The string ID for which a new translation should be added.
+ * @param $translation
+ *   An array of strings which constitute the new translation.
+ */
+function l10n_community_add_suggestion($langcode, $sid, $translation) {
+  global $user;
+
+  // Load source string and adjust translation whitespace based on source.
+  $source_string = db_result(db_query('SELECT value FROM {l10n_community_string} WHERE sid = %d', $sid));
+  $translation = l10n_community_pack_string($translation);
+  $translation = l10n_community_trim($translation, $source_string);
+
+  // Don't store empty translations.
+  if ($translation === '') {
+    return NULL;
+  }
+
+  // Look for an existing active translation, if any.
+  $existing = db_fetch_object(db_query("SELECT tid FROM {l10n_community_translation} WHERE sid = %d AND language = '%s' AND translation = '%s'", $sid, $langcode, $translation));
+  if (!empty($existing)) {
+    // The translation is already in the db. Make it an active suggestion again.
+    db_query("UPDATE {l10n_community_translation} SET is_suggestion = 1, is_active = 1 WHERE tid = %d", $existing->tid);
+    $tid = $existing->tid;
+  }
+  else {
+    // This is a new translation.
+    l10n_community_mock_translation($langcode, $sid);
+    // Insert the new suggestion.
+    db_query("INSERT INTO {l10n_community_translation} (sid, translation, language, uid_entered, time_entered, is_suggestion, is_active) VALUES (%d, '%s', '%s', %d, %d, 1, 1)", $sid, $translation, $langcode, $user->uid, time());
+    $tid = db_last_insert_id('l10n_community_translation', 'tid');
+  }
+
+  // Mark the existing or mock translation has having suggestions.
+  l10n_community_update_suggestion_status($langcode, $sid);
+
+  return $tid;
+}
+
+/**
+ * Marks a translation as declined.
+ *
+ * @param $langcode
+ *   The language of the declined translation.
+ * @param $sid
+ *   The string ID the translation belongs to.
+ * @param $tid
+ *   The translation ID of the translation.
+ */
+function l10n_community_decline_string($langcode, $sid, $tid) {
+  // Mark this translation as inactive.
+  db_query("UPDATE {l10n_community_translation} SET is_suggestion = 0, is_active = 0 WHERE tid = %d", $tid);
+
+  // Make sure the mock translation is there in case we declined the active translation.
+  l10n_community_mock_translation($langcode, $sid);
+  l10n_community_update_suggestion_status($langcode, $sid);
+}
+
+/**
+ * Updates the has_suggestion flag for the active translation.
+ *
+ * @param $langcode
+ *   The language of the string.
+ * @param $sid
+ *   The string ID that should be updated.
+ */
+function l10n_community_update_suggestion_status($langcode, $sid) {
+  // Let's see if we have any suggestions remaining in this language.
+  $count = db_result(db_query("SELECT COUNT(*) FROM {l10n_community_translation}
+      WHERE sid = %d AND is_suggestion = 1 AND is_active = 1 AND language = '%s'", $sid, $langcode));
+
+  // Update the status according to the number of suggestions.
+  db_query("UPDATE {l10n_community_translation} SET has_suggestion = %d
+      WHERE sid = %d AND is_suggestion = 0 AND is_active = 1 AND language = '%s'", $count ? 1 : 0, $sid, $langcode);
+}
+
+/**
+ * Marks a translation as approve.
+ *
+ * @param $langcode
+ *   The language of the approved translation.
+ * @param $sid
+ *   The string ID the translation belongs to.
+ * @param $tid
+ *   The translation ID of the translation.
+ */
+function l10n_community_approve_string($langcode, $sid, $tid) {
+  global $user;
+
+  // Remove placeholder translation record (which was there if
+  // first came suggestions, before an actual translation).
+  db_query("DELETE FROM {l10n_community_translation} WHERE sid = %d AND translation = '' AND language = '%s'", $sid, $langcode);
+
+  // Make the existing approved string a suggestion.
+  db_query("UPDATE {l10n_community_translation} SET is_suggestion = 1 WHERE sid = %d AND language = '%s' AND is_suggestion = 0 AND is_active = 1", $sid, $langcode);
+
+  // Mark this exact suggestion as active, and set approval time.
+  db_query("UPDATE {l10n_community_translation} SET time_approved = %d, uid_approved = %d, is_suggestion = 0, is_active = 1 WHERE tid = %d;", time(), $user->uid, $tid);
+  l10n_community_update_suggestion_status($langcode, $sid);
+}
+
+/**
+ * Unpacks a string as retrieved from the database.
+ *
+ * @param $string
+ *   The string with separation markers (NULL byte)
+ * @return
+ *   An array of strings with one element for each plural form in case of
+ *   a plural string, or one element in case of a regular string.
+ */
+function l10n_community_unpack_string($string) {
+  return explode("\0", $string);
+}
+
+/**
+ * Packs a string for storage in the database.
+ *
+ * @param $string
+ *   An array of strings.
+ * @return
+ *   A packed string with NULL bytes separating each string.
+ */
+function l10n_community_pack_string($strings) {
+  return implode("\0", $strings);
+}
+
+/**
  * Replace complex data filters (objects or arrays) with string representations.
  *
  * @param $filters
@@ -771,3 +921,49 @@ function l10n_community_flat_filters($filters) {
   }
   return $filters;
 }
+
+
+// = AJAX callbacks ============================================================
+
+/**
+ * Return a HTML list of projects, releases and counts of where strings
+ * appear in the managed projects.
+ *
+ * We could have been provided much more information, but usability should
+ * also be kept in mind. It is possible to investigate hidden information
+ * sources though, like tooltips on the release titles presented.
+ *
+ * This callback is invoked from JavaScript and is used as an AHAH provider.
+ *
+ * @param $langcode
+ *   Language code.
+ * @param $sid
+ *   Source string id.
+ */
+function l10n_community_string_details($langcode = NULL, $sid = 0) {
+  // Prevent devel module information.
+  $GLOBALS['devel_shutdown'] = FALSE;
+
+  // List of project releases, where this string is used.
+  $result = db_query('SELECT l.pid, p.title project_title, l.rid, r.title release_title, COUNT(l.lineno) as occurance_count FROM {l10n_community_line} l INNER JOIN {l10n_community_project} p ON l.pid = p.pid INNER JOIN {l10n_community_release} r ON l.rid = r.rid WHERE l.sid = %d AND p.status = 1 GROUP BY l.rid ORDER BY l.pid, l.rid', $sid);
+
+  $list = array();
+  $output = array();
+  $previous_project = '';
+  while ($instance = db_fetch_object($result)) {
+    if ($instance->project_title != $previous_project) {
+      if (!empty($list)) {
+        $output[] = join(', ', $list);
+      }
+      $list = array('<em>'. $instance->project_title .':</em> '. $instance->release_title .' ('. $instance->occurance_count .')');
+    }
+    else {
+      $list[] = $instance->release_title .' ('. $instance->occurance_count .')';
+    }
+    $previous_project = $instance->project_title;
+  }
+  $output[] = join(', ', $list);
+  print '<strong>'. t('Used in:') .'</strong>'. theme('item_list', $output);
+
+  exit;
+}
diff --git l10n_community/welcome.inc l10n_community/welcome.inc
index 48c380b..639a837 100644
--- l10n_community/welcome.inc
+++ l10n_community/welcome.inc
@@ -114,7 +114,7 @@ function l10n_community_pick_go() {
       '#value' => $language_code
     );
   }
-  
+
   if (count($language_list) > 1) {
     $projects_title = user_access('browse translations') ? t('And/or pick a project') : t('Or pick a project');
   }
diff --git l10n_remote/l10n_remote.module l10n_remote/l10n_remote.module
index 4c1c384..0e42aa2 100644
--- l10n_remote/l10n_remote.module
+++ l10n_remote/l10n_remote.module
@@ -141,7 +141,7 @@ function l10n_remote_xmlrpc_string_submit($langcode, $source, $translation, $uid
     //watchdog('l10n_community', 'Language not allowed for remote submission.', NULL, WATCHDOG_WARNING);
     return array('status' => FALSE, 'reason' => 'Language not accepted.');
   }
-  
+
   // Check if the user has permission to submit strings in this language.
   if (!(l10n_community_get_permission($langcode, $account) & L10N_PERM_SUGGEST)) {
     //watchdog('l10n_community', 'Not allowed to submit translations in this language remotely.', NULL, WATCHDOG_WARNING);
